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