1use crate::{
2 highlighter::HighlightTheme, list::ListSettings, notification::NotificationSettings,
3 scroll::ScrollbarMode, sheet::SheetSettings,
4};
5use gpui::{
6 App, Global, Hsla, IsZero as _, Pixels, SharedString, Window, WindowAppearance,
7 prelude::FluentBuilder as _, px,
8};
9pub use gpui_base::{
10 ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens, TextStyleToken,
11 TypographyTokens,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::{
16 ops::{Deref, DerefMut},
17 rc::Rc,
18 sync::Arc,
19 time::Duration,
20};
21
22mod color;
23mod mono_font;
24mod motion;
25mod registry;
26mod schema;
27mod system_font;
28mod theme_color;
29
30pub use color::*;
31pub use motion::*;
32pub use registry::*;
33pub use schema::*;
34pub use theme_color::*;
35
36pub fn init(cx: &mut App) {
37 registry::init(cx);
38
39 Theme::change(ThemeMode::Light, None, cx);
41 Theme::sync_scrollbar_appearance(cx);
42}
43
44pub trait ActiveTheme {
45 fn theme(&self) -> &Theme;
46}
47
48impl ActiveTheme for App {
49 #[inline(always)]
50 fn theme(&self) -> &Theme {
51 Theme::global(self)
52 }
53}
54
55fn default_true() -> bool {
56 true
57}
58
59const RADIUS_FULL: Pixels = px(9999.);
63
64const PLOT_APPEAR: Duration = Duration::from_millis(1000);
70
71fn plot_appear_easing(t: f32) -> f32 {
78 1. - (1. - t).powi(4)
79}
80
81const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
83const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
85const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
87const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
89
90const MOBILE_SCROLLBAR_THUMB_WIDTH: Pixels = px(3.);
94const MOBILE_SCROLLBAR_THUMB_INSET: Pixels = px(2.);
97const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(6.);
100const SCROLLBAR_THUMB_ACTIVE_WIDTH: Pixels = px(8.);
102const SCROLLBAR_THUMB_INSET: Pixels = px(4.);
104
105fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion {
115 gpui_base::PlotMotion::default()
116 .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1))
117 .with_enter(
118 gpui_base::motion::Transition::new(motion.duration_fast)
119 .easing(motion.easing_enter.clone()),
120 )
121 .with_exit(
122 gpui_base::motion::Transition::new(motion.duration_fast)
123 .easing(motion.easing_exit.clone()),
124 )
125 .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).ease(plot_appear_easing))
126}
127
128fn scrollbar_motion(mode: ScrollbarMode) -> gpui_base::ScrollbarMotion {
133 gpui_base::ScrollbarMotion::default()
134 .with_idle(SCROLLBAR_IDLE)
135 .with_enter(SCROLLBAR_ENTER)
136 .with_exit(SCROLLBAR_EXIT)
137 .with_expand(SCROLLBAR_EXPAND)
138 .with_entrance(gpui_base::ScrollbarEntrance::Fade)
139 .with_thumb_hover_entrance(match mode {
140 ScrollbarMode::Scrolling | ScrollbarMode::Always => gpui_base::ScrollbarEntrance::Fade,
141 ScrollbarMode::Hover => gpui_base::ScrollbarEntrance::SlideAndFade,
142 })
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
150#[non_exhaustive]
151pub struct Theme {
152 pub colors: ThemeColor,
153 #[serde(default)]
158 pub tokens: ThemeTokens,
159 pub highlight_theme: Arc<HighlightTheme>,
160 pub light_theme: Rc<ThemeConfig>,
161 pub dark_theme: Rc<ThemeConfig>,
162
163 pub mode: ThemeMode,
164 pub font_family: SharedString,
171 pub font_size: Pixels,
173 pub mono_font_family: SharedString,
186 pub mono_font_size: Pixels,
188 pub radius: Pixels,
190 pub radius_lg: Pixels,
192 pub shadow: bool,
193 #[serde(default = "default_true")]
201 pub focus_ring: bool,
202 pub transparent: Hsla,
203 #[serde(alias = "scrollbar_show")]
205 pub scrollbar_mode: ScrollbarMode,
206 #[serde(skip)]
208 pub notification: NotificationSettings,
209 pub list: ListSettings,
211 pub sheet: SheetSettings,
213 #[serde(skip)]
215 pub motion: MotionTokens,
216 #[serde(skip)]
218 pub cursor_blink: gpui_base::input::CursorBlink,
219}
220
221impl Default for Theme {
222 fn default() -> Self {
223 Self::from(&ThemeColor::default())
224 }
225}
226
227impl Deref for Theme {
228 type Target = ThemeColor;
229
230 fn deref(&self) -> &Self::Target {
231 &self.colors
232 }
233}
234
235impl DerefMut for Theme {
236 fn deref_mut(&mut self) -> &mut Self::Target {
237 &mut self.colors
238 }
239}
240
241impl Global for Theme {}
242
243impl Theme {
244 #[inline(always)]
246 pub fn global(cx: &App) -> &Theme {
247 cx.global::<Theme>()
248 }
249
250 #[inline(always)]
258 pub fn global_mut(cx: &mut App) -> &mut Theme {
259 cx.global_mut::<Theme>()
260 }
261
262 pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
296 Self::edit(cx, false, edit)
297 }
298
299 fn edit<R>(cx: &mut App, reload_mode: bool, edit: impl FnOnce(&mut Theme) -> R) -> R {
306 let theme = Theme::global_mut(cx);
307 let colors_before = theme.colors;
308 let tokens_before = theme.tokens;
309 let mode_before = theme.mode;
310 let light_before = theme.light_theme.clone();
311 let dark_before = theme.dark_theme.clone();
312 let fonts_before = (theme.font_family.clone(), theme.mono_font_family.clone());
313
314 let result = edit(theme);
315
316 theme
317 .tokens
318 .reconcile(&mut theme.colors, &colors_before, &tokens_before);
319 let mode_changed = theme.mode != mode_before;
320 let (config, config_before) = if theme.mode.is_dark() {
321 (&theme.dark_theme, &dark_before)
322 } else {
323 (&theme.light_theme, &light_before)
324 };
325 let installed_by_edit = mode_changed && !Rc::ptr_eq(config, config_before);
330 if (mode_changed || reload_mode) && !installed_by_edit {
331 let config = config.clone();
332 theme.apply_config(&config);
333 }
334 let fonts_changed =
335 (&theme.font_family, &theme.mono_font_family) != (&fonts_before.0, &fonts_before.1);
336 if mode_changed || reload_mode || fonts_changed {
337 system_font::resolve_default_font(cx);
338 mono_font::resolve_default_mono_font(cx);
339 }
340 Self::sync_base(cx);
341 cx.refresh_windows();
342 result
343 }
344
345 #[inline(always)]
347 pub fn is_dark(&self) -> bool {
348 self.mode.is_dark()
349 }
350
351 pub fn theme_name(&self) -> &SharedString {
353 if self.is_dark() {
354 &self.dark_theme.name
355 } else {
356 &self.light_theme.name
357 }
358 }
359
360 pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
362 let appearance = window
365 .as_ref()
366 .map(|window| window.appearance())
367 .unwrap_or_else(|| cx.window_appearance());
368
369 Self::change(appearance, window, cx);
370 }
371
372 pub fn sync_scrollbar_appearance(cx: &mut App) {
374 let mode = if cx.should_auto_hide_scrollbars() {
375 ScrollbarMode::Scrolling
376 } else {
377 ScrollbarMode::Hover
378 };
379 Self::set_scrollbar_mode(mode, cx);
380 }
381
382 pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
385 Self::update(cx, |theme| theme.scrollbar_mode = mode);
386 }
387
388 pub fn change(mode: impl Into<ThemeMode>, _window: Option<&mut Window>, cx: &mut App) {
397 let mode = mode.into();
398 if !cx.has_global::<Theme>() {
399 let mut theme = Theme::default();
400 theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
401 theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
402 cx.set_global(theme);
403 }
404
405 Self::edit(cx, true, |theme| theme.mode = mode);
406 }
407
408 fn base_theme(&self) -> gpui_base::Theme {
411 let mut theme = gpui_base::Theme::default();
412 theme.appearance = if self.mode.is_dark() {
413 gpui_base::ThemeAppearance::Dark
414 } else {
415 gpui_base::ThemeAppearance::Light
416 };
417 theme.tokens = self.semantic_tokens();
418 theme.scrollbar = gpui_base::ScrollbarTheme::new()
419 .with_mode(self.scrollbar_mode)
420 .with_motion(scrollbar_motion(self.scrollbar_mode))
421 .with_styles(
422 gpui_base::ScrollbarStyles::default()
423 .track(|style| style.bg(self.scrollbar))
424 .track_hover(|style| style.bg(self.scrollbar))
425 .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
426 .thumb(|style| {
427 style
428 .bg(self.tokens.scrollbar_thumb)
429 .radius(self.radius)
430 .when(gpui_base::is_mobile(), |style| {
431 style
432 .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
433 .inset(MOBILE_SCROLLBAR_THUMB_INSET)
434 .radius(RADIUS_FULL)
435 })
436 })
437 .thumb_hover(|style| {
438 style
439 .bg(self.tokens.scrollbar_thumb_hover)
440 .radius(self.radius)
441 .when(gpui_base::is_mobile(), |style| {
442 style
443 .width(SCROLLBAR_THUMB_HOVER_WIDTH)
444 .inset(SCROLLBAR_THUMB_INSET)
445 })
446 })
447 .thumb_active(|style| {
448 style
449 .bg(self.tokens.scrollbar_thumb_hover)
450 .radius(self.radius)
451 .when(gpui_base::is_mobile(), |style| {
452 style
453 .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
454 .inset(SCROLLBAR_THUMB_INSET)
455 })
456 }),
457 );
458 theme.resizable.handle = Some(self.border);
459 theme.resizable.active_handle = Some(self.drag_border);
460 theme.plot = gpui_base::PlotTheme::new().with_motion(plot_motion(&self.motion));
461 theme.cursor_blink = self.cursor_blink;
462 theme
463 }
464
465 pub fn sync_base(cx: &mut App) {
480 let theme = Theme::global(cx).clone();
481 let base_theme = theme.base_theme();
482 cx.set_global(base_theme);
483 crate::text::install_text_view_defaults(&theme, cx);
484 }
485
486 #[inline]
491 pub fn input_background(&self) -> Hsla {
492 if self.is_dark() {
493 self.input.mix_oklab(self.transparent, 0.3)
494 } else {
495 self.background
496 }
497 }
498
499 #[inline]
501 pub(crate) fn editor_background(&self) -> Hsla {
502 self.highlight_theme
503 .style
504 .editor_background
505 .unwrap_or_else(|| self.input_background())
506 }
507
508 pub fn semantic_tokens(&self) -> SemanticThemeTokens {
512 let mut tokens = SemanticThemeTokens::default();
513 tokens.colors = self.color_tokens();
514 tokens.radius = self.radius_tokens();
515 tokens.spacing = self.spacing_tokens();
516 tokens.typography = self.typography_tokens();
517 tokens.shadow = self.shadow_tokens();
518 tokens
519 }
520
521 pub fn motion_tokens(&self) -> &MotionTokens {
523 &self.motion
524 }
525
526 pub fn color_tokens(&self) -> ColorTokens {
527 let mut colors = ColorTokens::default();
528 colors.background = self.background;
529 colors.foreground = self.foreground;
530 colors.surface = self.popover;
531 colors.surface_foreground = self.popover_foreground;
532 colors.primary = self.primary;
533 colors.primary_foreground = self.primary_foreground;
534 colors.secondary = self.secondary;
535 colors.secondary_foreground = self.secondary_foreground;
536 colors.muted = self.muted;
537 colors.muted_foreground = self.muted_foreground;
538 colors.accent = self.accent;
539 colors.accent_foreground = self.accent_foreground;
540 colors.destructive = self.danger;
541 colors.destructive_foreground = self.danger_foreground;
542 colors.border = self.border;
543 colors.input = self.input;
544 colors.ring = self.ring;
545 colors.selection = self.selection;
546 colors
547 }
548
549 pub fn radius_full(&self) -> Pixels {
558 if self.radius.is_zero() {
559 px(0.)
560 } else {
561 RADIUS_FULL
562 }
563 }
564
565 pub fn radius_2xl(&self) -> Pixels {
570 self.radius * 2.5
571 }
572
573 pub fn radius_3xl(&self) -> Pixels {
575 self.radius * 3.
576 }
577
578 pub fn radius_4xl(&self) -> Pixels {
580 self.radius * 3.5
581 }
582
583 pub fn radius_tokens(&self) -> RadiusTokens {
584 let mut radius = RadiusTokens::default();
585 radius.none = px(0.);
586 radius.sm = self.radius / 2.;
587 radius.md = self.radius;
588 radius.lg = self.radius_lg;
589 radius.xl = self.radius * 2.;
590 radius.full = self.radius_full();
591 radius
592 }
593
594 pub fn spacing_tokens(&self) -> SpacingTokens {
595 SpacingTokens::default()
596 }
597
598 pub fn typography_tokens(&self) -> TypographyTokens {
599 let mut tokens = TypographyTokens::default();
600 tokens.sans = self.font_family.clone();
601 tokens.mono = self.mono_font_family.clone();
602 tokens.md.size = self.font_size;
603 tokens.mono_md.size = self.mono_font_size;
604 tokens
605 }
606
607 pub fn shadow_tokens(&self) -> ShadowTokens {
608 if self.shadow {
609 ShadowTokens::elevations(self.transparent.alpha(0.18))
610 } else {
611 ShadowTokens::default()
612 }
613 }
614
615 pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
619 let colors = tokens.colors;
620 self.background = colors.background;
621 self.foreground = colors.foreground;
622 self.popover = colors.surface;
623 self.popover_foreground = colors.surface_foreground;
624 self.primary = colors.primary;
625 self.primary_foreground = colors.primary_foreground;
626 self.secondary = colors.secondary;
627 self.secondary_foreground = colors.secondary_foreground;
628 self.muted = colors.muted;
629 self.muted_foreground = colors.muted_foreground;
630 self.accent = colors.accent;
631 self.accent_foreground = colors.accent_foreground;
632 self.danger = colors.destructive;
633 self.danger_foreground = colors.destructive_foreground;
634 self.border = colors.border;
635 self.input = colors.input;
636 self.ring = colors.ring;
637
638 self.tokens.background = colors.background.into();
639 self.tokens.popover = colors.surface.into();
640 self.tokens.primary = colors.primary.into();
641 self.tokens.secondary = colors.secondary.into();
642 self.tokens.muted = colors.muted.into();
643 self.tokens.accent = colors.accent.into();
644 self.tokens.danger = colors.destructive.into();
645
646 self.radius = tokens.radius.md;
647 self.radius_lg = tokens.radius.lg;
648 self.font_family = tokens.typography.sans.clone();
649 self.mono_font_family = tokens.typography.mono.clone();
650 self.font_size = tokens.typography.md.size;
651 self.mono_font_size = tokens.typography.mono_md.size;
652 self.shadow = !tokens.shadow.sm.is_empty()
653 || !tokens.shadow.md.is_empty()
654 || !tokens.shadow.lg.is_empty();
655 }
656
657 pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
660 let mut tokens = self.semantic_tokens();
661 config.apply_to(&mut tokens);
662 tokens
663 }
664
665 pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
668 let tokens = self.resolve_semantic_config(config);
669 self.apply_semantic_tokens(&tokens);
670 tokens
671 }
672
673 pub fn apply_semantic_config_str(
675 &mut self,
676 content: &str,
677 ) -> anyhow::Result<SemanticThemeTokens> {
678 let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
679 Ok(self.apply_semantic_config(&config.tokens))
680 }
681}
682
683#[cfg(test)]
684mod semantic_token_tests {
685 use gpui::{Hsla, IsZero as _, px};
686
687 use super::{RADIUS_FULL, Theme};
688
689 #[test]
690 fn semantic_colors_are_a_live_projection_of_legacy_fields() {
691 let mut theme = Theme::default();
692 let primary = Hsla::default().alpha(0.42);
693 theme.primary = primary;
694
695 assert_eq!(theme.color_tokens().primary, primary);
696 assert_eq!(theme.semantic_tokens().colors.primary, primary);
697 }
698
699 #[test]
700 fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
701 let mut theme = Theme::default();
702 let component_color = theme.button_primary;
703 let mut tokens = theme.semantic_tokens();
704 tokens.colors.primary = Hsla::default().alpha(0.25);
705 tokens.colors.destructive = Hsla::default().alpha(0.75);
706 tokens.radius.md = px(10.);
707
708 theme.apply_semantic_tokens(&tokens);
709
710 assert_eq!(theme.primary, tokens.colors.primary);
711 assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
712 assert_eq!(theme.danger, tokens.colors.destructive);
713 assert_eq!(theme.radius, px(10.));
714 assert_eq!(theme.button_primary, component_color);
715 }
716
717 #[test]
718 fn square_themes_square_off_pills_and_circles() {
719 let mut theme = Theme::default();
720 assert_eq!(theme.radius_full(), RADIUS_FULL);
721 assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
722
723 theme.radius = px(0.);
726 assert_eq!(theme.radius_full(), px(0.));
727 assert_eq!(theme.radius_tokens().full, px(0.));
728 }
729
730 #[test]
731 fn larger_surface_radii_follow_the_theme_radius() {
732 let mut theme = Theme::default();
733 assert!(theme.radius_tokens().xl < theme.radius_2xl());
734 assert!(theme.radius_2xl() < theme.radius_3xl());
735 assert!(theme.radius_3xl() < theme.radius_4xl());
736
737 theme.radius = px(10.);
738 assert_eq!(theme.radius_2xl(), px(25.));
739 assert_eq!(theme.radius_3xl(), px(30.));
740 assert_eq!(theme.radius_4xl(), px(35.));
741
742 theme.radius = px(0.);
743 assert_eq!(theme.radius_2xl(), px(0.));
744 assert_eq!(theme.radius_3xl(), px(0.));
745 assert_eq!(theme.radius_4xl(), px(0.));
746 }
747
748 #[test]
749 fn base_projection_carries_a_square_radius_to_the_scrollbar() {
750 let mut theme = Theme::default();
751 assert!(!theme.base_theme().tokens.radius.md.is_zero());
752
753 theme.radius = px(0.);
756 assert!(theme.base_theme().tokens.radius.md.is_zero());
757 }
758
759 #[test]
760 fn disabled_legacy_shadows_project_to_empty_elevations() {
761 let mut theme = Theme::default();
762 theme.shadow = false;
763
764 let shadows = theme.shadow_tokens();
765 assert!(shadows.sm.is_empty());
766 assert!(shadows.md.is_empty());
767 assert!(shadows.lg.is_empty());
768 }
769}
770
771impl From<&ThemeColor> for Theme {
772 fn from(colors: &ThemeColor) -> Self {
773 Theme {
774 mode: ThemeMode::default(),
775 transparent: Hsla::transparent_black(),
776 font_family: ".SystemUIFont".into(),
777 font_size: px(16.),
778 mono_font_family: mono_font::default_mono_font_family(),
779 mono_font_size: px(13.),
780 radius: px(6.),
781 radius_lg: px(8.),
782 shadow: true,
783 focus_ring: true,
784 scrollbar_mode: ScrollbarMode::default(),
785 notification: NotificationSettings::default(),
786 list: ListSettings::default(),
787 colors: *colors,
788 tokens: ThemeTokens::from(colors),
789 light_theme: Rc::new(ThemeConfig::default()),
790 dark_theme: Rc::new(ThemeConfig::default()),
791 highlight_theme: HighlightTheme::default_light(),
792 sheet: SheetSettings::default(),
793 motion: MotionTokens::default(),
794 cursor_blink: gpui_base::input::CursorBlink::default(),
795 }
796 }
797}
798
799#[derive(
800 Debug,
801 Clone,
802 Copy,
803 Default,
804 PartialEq,
805 PartialOrd,
806 Eq,
807 Ord,
808 Hash,
809 Serialize,
810 Deserialize,
811 JsonSchema,
812)]
813#[serde(rename_all = "snake_case")]
814pub enum ThemeMode {
815 #[default]
816 Light,
817 Dark,
818}
819
820impl ThemeMode {
821 #[inline(always)]
822 pub fn is_dark(&self) -> bool {
823 matches!(self, Self::Dark)
824 }
825
826 pub fn name(&self) -> &'static str {
828 match self {
829 ThemeMode::Light => "light",
830 ThemeMode::Dark => "dark",
831 }
832 }
833}
834
835impl From<WindowAppearance> for ThemeMode {
836 fn from(appearance: WindowAppearance) -> Self {
837 match appearance {
838 WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
839 WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
840 }
841 }
842}
843
844#[cfg(test)]
845mod update_tests {
846 use super::*;
847 use gpui::{TestAppContext, linear_color_stop, linear_gradient};
848
849 fn gradient(from: Hsla, to: Hsla) -> ThemeToken {
850 ThemeToken::new(
851 from,
852 linear_gradient(135., linear_color_stop(from, 0.), linear_color_stop(to, 1.)),
853 )
854 }
855
856 #[gpui::test]
859 fn editing_colors_updates_the_tokens_and_the_base_projection(cx: &mut TestAppContext) {
860 cx.update(|cx| {
861 init(cx);
862 let sidebar = gpui::rgb(0x123456).into();
863 let primary = gpui::rgb(0xabcdef).into();
864
865 Theme::update(cx, |theme| {
866 theme.sidebar = sidebar;
867 theme.colors.primary = primary;
868 theme.radius = px(0.);
869 });
870
871 let theme = Theme::global(cx);
872 assert_eq!(theme.tokens.sidebar.color, sidebar);
873 assert_eq!(theme.tokens.sidebar.background, sidebar.into());
874 assert_eq!(theme.tokens.primary.color, primary);
875 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, primary);
876 assert!(gpui_base::Theme::global(cx).tokens.radius.md.is_zero());
877 });
878 }
879
880 #[gpui::test]
883 fn replacing_the_palette_rewrites_every_token(cx: &mut TestAppContext) {
884 cx.update(|cx| {
885 init(cx);
886 let palette = *ThemeColor::dark();
887
888 Theme::update(cx, |theme| theme.colors = palette);
889
890 assert_eq!(Theme::global(cx).tokens, ThemeTokens::from(palette));
891 });
892 }
893
894 #[gpui::test]
897 fn a_gradient_survives_until_its_own_color_is_edited(cx: &mut TestAppContext) {
898 cx.update(|cx| {
899 init(cx);
900 let from = gpui::rgb(0x4f46e5).into();
901 let to = gpui::rgb(0x06b6d4).into();
902 let token = gradient(from, to);
903 Theme::update(cx, |theme| theme.tokens.primary = token);
904 assert_eq!(Theme::global(cx).primary, from);
907
908 Theme::update(cx, |theme| theme.secondary = gpui::rgb(0x222222).into());
909 assert_eq!(Theme::global(cx).tokens.primary, token);
910
911 let solid = gpui::rgb(0x999999).into();
912 Theme::update(cx, |theme| theme.primary = solid);
913 assert_eq!(Theme::global(cx).tokens.primary, solid.into());
914 });
915 }
916
917 #[gpui::test]
920 fn setting_the_mode_loads_that_modes_theme(cx: &mut TestAppContext) {
921 cx.update(|cx| {
922 init(cx);
923 let light_background = Theme::global(cx).background;
924
925 Theme::update(cx, |theme| theme.mode = ThemeMode::Dark);
926
927 let theme = Theme::global(cx);
928 assert!(theme.is_dark());
929 assert_ne!(theme.background, light_background);
930 assert_eq!(theme.tokens.background.color, theme.background);
931 assert_eq!(
932 gpui_base::Theme::global(cx).appearance,
933 gpui_base::ThemeAppearance::Dark
934 );
935 assert_eq!(
936 gpui_base::Theme::global(cx).tokens.colors.background,
937 theme.background
938 );
939 });
940 }
941
942 #[gpui::test]
946 fn applying_a_config_keeps_its_gradients(cx: &mut TestAppContext) {
947 cx.update(|cx| {
948 init(cx);
949 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
950 "name": "Gradient",
951 "mode": "light",
952 "colors": {
953 "primary": "#4F46E5",
954 "primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)"
955 }
956 }))
957 .unwrap();
958 let config = Rc::new(config);
959
960 Theme::update(cx, |theme| theme.apply_config(&config));
961
962 let theme = Theme::global(cx);
963 assert_eq!(theme.tokens.primary.color, theme.primary);
964 assert_ne!(
965 theme.tokens.primary.background,
966 theme.primary.into(),
967 "the gradient must survive the reconcile"
968 );
969 });
970 }
971
972 #[gpui::test]
976 fn edits_after_applying_a_config_of_the_other_mode_survive(cx: &mut TestAppContext) {
977 cx.update(|cx| {
978 init(cx);
979 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
980 "name": "Rounded Dark",
981 "mode": "dark",
982 "radius": 12,
983 "colors": { "primary": "#4F46E5" }
984 }))
985 .unwrap();
986 let config = Rc::new(config);
987 assert!(!Theme::global(cx).is_dark());
988
989 let red = gpui::red();
990 Theme::update(cx, |theme| {
991 theme.apply_config(&config);
992 theme.radius = px(0.);
993 theme.colors.primary = red;
994 });
995
996 let theme = Theme::global(cx);
997 assert!(theme.is_dark());
998 assert!(Rc::ptr_eq(&theme.dark_theme, &config));
999 assert_eq!(theme.radius, px(0.), "the file's radius must not reload");
1000 assert_eq!(theme.primary, red);
1001 assert_eq!(theme.tokens.primary, red.into());
1002 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, red);
1003 });
1004 }
1005
1006 #[gpui::test]
1007 fn update_returns_the_closure_result(cx: &mut TestAppContext) {
1008 cx.update(|cx| {
1009 init(cx);
1010 let radius = Theme::update(cx, |theme| {
1011 theme.radius = px(6.);
1012 theme.radius
1013 });
1014 assert_eq!(radius, px(6.));
1015 });
1016 }
1017}
1018
1019#[cfg(test)]
1020mod base_theme_projection_tests {
1021 use super::*;
1022 use gpui::TestAppContext;
1023
1024 #[gpui::test]
1025 fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
1026 cx.update(|cx| {
1027 init(cx);
1028 assert_styled_projection(cx);
1029
1030 Theme::change(ThemeMode::Dark, None, cx);
1031 assert_styled_projection(cx);
1032
1033 Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
1034 assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
1035 assert_eq!(
1036 gpui_base::Theme::global(cx).scrollbar.mode(),
1037 gpui_base::ScrollbarMode::Always
1038 );
1039 assert_styled_projection(cx);
1040 });
1041 }
1042
1043 #[gpui::test]
1044 fn cursor_blink_survives_theme_updates_and_mode_changes(cx: &mut TestAppContext) {
1045 cx.update(|cx| {
1046 init(cx);
1047 Theme::update(cx, |theme| {
1048 theme.cursor_blink = gpui_base::input::CursorBlink::steady();
1049 });
1050 assert_eq!(
1051 gpui_base::Theme::global(cx).cursor_blink,
1052 gpui_base::input::CursorBlink::steady()
1053 );
1054 Theme::change(ThemeMode::Dark, None, cx);
1055 assert_eq!(
1056 Theme::global(cx).cursor_blink,
1057 gpui_base::input::CursorBlink::steady()
1058 );
1059 assert_eq!(
1060 gpui_base::Theme::global(cx).cursor_blink,
1061 gpui_base::input::CursorBlink::steady()
1062 );
1063 });
1064 }
1065
1066 #[gpui::test]
1067 fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
1068 cx.update(|cx| {
1069 init(cx);
1070
1071 let bare = gpui_base::ScrollbarMotion::default();
1073 assert_eq!(bare.enter(), Duration::ZERO);
1074 assert_eq!(bare.exit(), Duration::ZERO);
1075 assert_eq!(bare.expand(), Duration::ZERO);
1076
1077 Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
1078 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1079 assert_eq!(motion.idle(), SCROLLBAR_IDLE);
1080 assert_eq!(motion.enter(), SCROLLBAR_ENTER);
1081 assert_eq!(motion.exit(), SCROLLBAR_EXIT);
1082 assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
1083 assert_eq!(
1084 motion.entrance(),
1085 gpui_base::ScrollbarEntrance::Fade,
1086 "scroll-revealed scrollbars fade in without sliding"
1087 );
1088
1089 Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
1090 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1091 assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
1092 assert_eq!(
1093 motion.thumb_hover_entrance(),
1094 gpui_base::ScrollbarEntrance::SlideAndFade
1095 );
1096 });
1097 }
1098
1099 #[test]
1100 fn default_motion_tokens_form_a_coherent_semantic_scale() {
1101 let theme = Theme::default();
1102 let motion = theme.motion_tokens();
1103
1104 assert_eq!(motion.duration_instant, Duration::ZERO);
1105 assert!(motion.duration_fast < motion.duration_normal);
1106 assert!(motion.duration_normal < motion.duration_slow);
1107 assert!(motion.distance_short.0 < motion.distance_medium.0);
1108 assert_eq!(motion.easing_enter.sample(0.0), 0.0);
1109 assert_eq!(motion.easing_enter.sample(1.0), 1.0);
1110 }
1111
1112 fn assert_styled_projection(cx: &App) {
1113 let theme = Theme::global(cx);
1114 let base = gpui_base::Theme::global(cx);
1115
1116 assert_eq!(base.tokens, theme.semantic_tokens());
1117 assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
1118 assert_eq!(
1119 base.scrollbar.motion(),
1120 scrollbar_motion(theme.scrollbar_mode)
1121 );
1122 assert_eq!(base.resizable.handle, Some(theme.border));
1123 assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
1124 }
1125
1126 #[gpui::test]
1127 fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
1128 fn assert_close(left: ColorTokens, right: ColorTokens) {
1129 macro_rules! color {
1130 ($field:ident) => {
1131 assert!(
1132 (left.$field.h - right.$field.h).abs() < 1e-6
1133 && (left.$field.s - right.$field.s).abs() < 1e-6
1134 && (left.$field.l - right.$field.l).abs() < 1e-6
1135 && (left.$field.a - right.$field.a).abs() < 1e-6,
1136 "{} differs: {:?} != {:?}",
1137 stringify!($field),
1138 left.$field,
1139 right.$field
1140 );
1141 };
1142 }
1143 color!(background);
1144 color!(foreground);
1145 color!(surface);
1146 color!(surface_foreground);
1147 color!(primary);
1148 color!(primary_foreground);
1149 color!(secondary);
1150 color!(secondary_foreground);
1151 color!(muted);
1152 color!(muted_foreground);
1153 color!(accent);
1154 color!(accent_foreground);
1155 color!(destructive);
1156 color!(destructive_foreground);
1157 color!(border);
1158 color!(input);
1159 color!(ring);
1160 color!(selection);
1161 }
1162
1163 cx.update(crate::init);
1164 cx.update(|cx| {
1165 assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
1166 });
1167
1168 cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
1169 cx.update(|cx| {
1170 assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
1171 });
1172 }
1173}