Skip to main content

gpui_component/theme/
mod.rs

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    // Ensure theme is loaded directly on startup for WASM compatibility
40    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
59/// The radius that rounds a shape as far as its own size allows, giving a
60/// circle or a pill. Any value past half the shorter side is clamped by the
61/// renderer, so this is simply "as round as it goes".
62const RADIUS_FULL: Pixels = px(9999.);
63
64/// How long a chart's data takes to draw in the first time it is painted.
65///
66/// The mainstream chart libraries agree on about a second: ECharts, Chart.js
67/// and Highcharts all default to 1000 ms, ApexCharts to 800 ms and Recharts to
68/// 1500 ms.
69const PLOT_APPEAR: Duration = Duration::from_millis(1000);
70
71/// The curve a chart's data draws in on: Chart.js' default `easeOutQuart`.
72///
73/// Not the design system's enter curve. That one is an exponential ease-out
74/// built for a popover, done nine-tenths of the way in the first quarter, which
75/// makes a second of drawing in read as a flash. The quartic still leads with
76/// most of the motion but leaves the data a visible glide into place.
77fn plot_appear_easing(t: f32) -> f32 {
78    1. - (1. - t).powi(4)
79}
80
81/// How long the scrollbar stays visible after the last scroll, drag, or hover.
82const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
83/// How long the scrollbar takes to appear.
84const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
85/// How long the scrollbar takes to fade away once the idle hold expires.
86const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
87/// How long the thumb takes to reach its hovered or resting width.
88const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
89
90/// The resting thumb width on iOS and Android, matching the 3pt indicator
91/// those platforms draw. Hover and drag keep Base's desktop widths, so a
92/// grabbed thumb still grows under the finger.
93const MOBILE_SCROLLBAR_THUMB_WIDTH: Pixels = px(3.);
94/// How far the resting thumb sits from the edge on iOS and Android. Base's
95/// desktop inset leaves a 3px thumb floating too far from the edge.
96const MOBILE_SCROLLBAR_THUMB_INSET: Pixels = px(2.);
97/// Base's resting thumb width, restated so the hovered thumb keeps it when
98/// the mobile resting width would otherwise cascade into it.
99const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(6.);
100/// Base's dragged thumb width, restated for the same reason.
101const SCROLLBAR_THUMB_ACTIVE_WIDTH: Pixels = px(8.);
102/// Base's hovered and dragged thumb inset, restated for the same reason.
103const SCROLLBAR_THUMB_INSET: Pixels = px(4.);
104
105/// The plot hover motion this design system projects onto Base.
106///
107/// A pointer chases the cursor across neighbouring data, so it has to arrive
108/// well within the time the cursor takes to reach the next datum: ECharts moves
109/// its axis pointer over 200 ms on an exponential ease-out, which is most of
110/// the way there in the first third. The fast tier as a critically damped
111/// response lands in the same place, and the tolerance is sub-pixel so the
112/// spring rests once nothing visible moves. The hover fades on the same tier.
113/// The data draws in over [`PLOT_APPEAR`] on [`plot_appear_easing`].
114fn 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
128/// The scrollbar motion this design system projects onto Base.
129///
130/// Scrolling and track hover reveal a scrollbar by fading it in place. In hover
131/// mode, pointing at the thumb slides it in from the nearest edge as it fades.
132fn 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/// The global theme configuration.
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
147pub struct Theme {
148    pub colors: ThemeColor,
149    /// Component-specific resolved tokens retained for legacy compatibility.
150    ///
151    /// New application-owned presentation should use [`Self::semantic_tokens`]
152    /// rather than extending this legacy surface.
153    #[serde(default)]
154    pub tokens: ThemeTokens,
155    pub highlight_theme: Arc<HighlightTheme>,
156    pub light_theme: Rc<ThemeConfig>,
157    pub dark_theme: Rc<ThemeConfig>,
158
159    pub mode: ThemeMode,
160    /// The font family for the application, default is `.SystemUIFont`.
161    ///
162    /// When the system font resolves to an installed fallback family instead
163    /// of itself (Linux desktops without the family GPUI maps it to),
164    /// [`Theme::change`] names that family here, so every text lookup hits
165    /// the font cache. A family set explicitly is used as-is.
166    pub font_family: SharedString,
167    /// The base font size for the application, default is 16px.
168    pub font_size: Pixels,
169    /// The monospace font family for the application.
170    ///
171    /// Defaults to:
172    ///
173    /// - macOS: `Menlo`
174    /// - Windows: `Consolas`
175    /// - Linux: `DejaVu Sans Mono`
176    ///
177    /// When that default is not installed, [`Theme::change`] swaps it for the
178    /// first installed alternative (`Monaco`, `Cascadia Mono`, `Noto Sans Mono`
179    /// and the like) and finally `.SystemUIFont`, so a missing font cannot
180    /// crash text layout. A family set explicitly is used as-is.
181    pub mono_font_family: SharedString,
182    /// The monospace font size for the application, default is 13px.
183    pub mono_font_size: Pixels,
184    /// Radius for the general elements.
185    pub radius: Pixels,
186    /// Radius for the large elements, e.g.: Dialog, Notification border radius.
187    pub radius_lg: Pixels,
188    pub shadow: bool,
189    /// Whether focused controls draw a ring outside their border, default true.
190    ///
191    /// The ring is painted outside the element, so any ancestor that clips its
192    /// content will cut it off. An application whose layout clips heavily can
193    /// turn it off here: focused controls then show only their tinted border,
194    /// or a 1px ring just inside their edge when they have none. Neither costs
195    /// space or can be clipped.
196    #[serde(default = "default_true")]
197    pub focus_ring: bool,
198    pub transparent: Hsla,
199    /// Show the scrollbar mode, default: Scrolling
200    #[serde(alias = "scrollbar_show")]
201    pub scrollbar_mode: ScrollbarMode,
202    /// The notification setting.
203    #[serde(skip)]
204    pub notification: NotificationSettings,
205    /// The list settings.
206    pub list: ListSettings,
207    /// The sheet settings.
208    pub sheet: SheetSettings,
209    /// Semantic motion policy for styled components.
210    #[serde(skip)]
211    pub motion: MotionTokens,
212}
213
214impl Default for Theme {
215    fn default() -> Self {
216        Self::from(&ThemeColor::default())
217    }
218}
219
220impl Deref for Theme {
221    type Target = ThemeColor;
222
223    fn deref(&self) -> &Self::Target {
224        &self.colors
225    }
226}
227
228impl DerefMut for Theme {
229    fn deref_mut(&mut self) -> &mut Self::Target {
230        &mut self.colors
231    }
232}
233
234impl Global for Theme {}
235
236impl Theme {
237    /// Returns the global theme reference
238    #[inline(always)]
239    pub fn global(cx: &App) -> &Theme {
240        cx.global::<Theme>()
241    }
242
243    /// Returns the global theme mutable reference.
244    ///
245    /// An edit made through this reference reaches nothing but the field it
246    /// touches: [`Theme::tokens`] keeps the colors it had, and so does the
247    /// Base projection until [`Theme::sync_base`] rebuilds it. Prefer
248    /// [`Theme::update`], which does both after the edit and refreshes every
249    /// window. Keep this for an edit that must not trigger any of that.
250    #[inline(always)]
251    pub fn global_mut(cx: &mut App) -> &mut Theme {
252        cx.global_mut::<Theme>()
253    }
254
255    /// Edits the global theme and keeps every copy of it in step.
256    ///
257    /// The theme holds the same colors twice — [`Theme::colors`] as solid
258    /// colors and [`Theme::tokens`] as renderable backgrounds that may carry a
259    /// gradient — and the Base layer keeps a projection of its own for the
260    /// scrollbar and resize handles. Editing one of them through
261    /// [`Theme::global_mut`] leaves the others where they were, so a sidebar
262    /// can paint its text from the new colors and its background from the old
263    /// tokens. This is the write path that cannot drift:
264    ///
265    /// ```ignore
266    /// Theme::update(cx, |theme| {
267    ///     theme.colors = my_colors;
268    ///     theme.radius = px(8.);
269    /// });
270    /// ```
271    ///
272    /// After the closure returns, a color edited on `colors` replaces its
273    /// token (dropping any gradient — the edit asked for that solid color), a
274    /// token edited on its own writes its solid color back to `colors`, an
275    /// untouched field keeps the gradient a theme file gave it, the Base
276    /// projection is rebuilt, and every window is refreshed.
277    ///
278    /// A field the closure sets to the value it already had counts as
279    /// untouched: assigning a whole palette keeps the gradient of any field
280    /// whose color did not change. Edit the token to replace one.
281    ///
282    /// Setting [`Theme::mode`] loads that mode's registered theme, the same
283    /// as [`Theme::change`]; that load replaces the colors, so edit colors in
284    /// a second `update` after switching mode rather than in the same closure.
285    /// [`Theme::apply_config`] installs a theme file and switches to its mode
286    /// in one step, and the closure may go on editing after it — nothing is
287    /// loaded over its edits.
288    pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
289        Self::edit(cx, false, edit)
290    }
291
292    /// The write path behind [`Theme::update`] and [`Theme::change`].
293    ///
294    /// `reload_mode` loads the current mode's registered theme even when the
295    /// mode did not change, which is what `change` promises: a caller that
296    /// swapped [`Theme::light_theme`] or [`Theme::dark_theme`] and then asks
297    /// for that mode gets the new theme applied.
298    fn edit<R>(cx: &mut App, reload_mode: bool, edit: impl FnOnce(&mut Theme) -> R) -> R {
299        let theme = Theme::global_mut(cx);
300        let colors_before = theme.colors;
301        let tokens_before = theme.tokens;
302        let mode_before = theme.mode;
303        let light_before = theme.light_theme.clone();
304        let dark_before = theme.dark_theme.clone();
305        let fonts_before = (theme.font_family.clone(), theme.mono_font_family.clone());
306
307        let result = edit(theme);
308
309        theme
310            .tokens
311            .reconcile(&mut theme.colors, &colors_before, &tokens_before);
312        let mode_changed = theme.mode != mode_before;
313        let (config, config_before) = if theme.mode.is_dark() {
314            (&theme.dark_theme, &dark_before)
315        } else {
316            (&theme.light_theme, &light_before)
317        };
318        // `apply_config` registers the file it applies and switches to its
319        // mode, so a mode change that arrives with a newly registered config
320        // has already loaded it. Loading it again would put the file's radius,
321        // fonts and colors back over whatever the closure edited after it.
322        let installed_by_edit = mode_changed && !Rc::ptr_eq(config, config_before);
323        if (mode_changed || reload_mode) && !installed_by_edit {
324            let config = config.clone();
325            theme.apply_config(&config);
326        }
327        let fonts_changed =
328            (&theme.font_family, &theme.mono_font_family) != (&fonts_before.0, &fonts_before.1);
329        if mode_changed || reload_mode || fonts_changed {
330            system_font::resolve_default_font(cx);
331            mono_font::resolve_default_mono_font(cx);
332        }
333        Self::sync_base(cx);
334        cx.refresh_windows();
335        result
336    }
337
338    /// Returns true if the theme is dark.
339    #[inline(always)]
340    pub fn is_dark(&self) -> bool {
341        self.mode.is_dark()
342    }
343
344    /// Returns the current theme name.
345    pub fn theme_name(&self) -> &SharedString {
346        if self.is_dark() {
347            &self.dark_theme.name
348        } else {
349            &self.light_theme.name
350        }
351    }
352
353    /// Sync the theme with the system appearance
354    pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
355        // Better use window.appearance() for avoid error on Linux.
356        // https://github.com/longbridge/gpui-kit/issues/104
357        let appearance = window
358            .as_ref()
359            .map(|window| window.appearance())
360            .unwrap_or_else(|| cx.window_appearance());
361
362        Self::change(appearance, window, cx);
363    }
364
365    /// Sync the Scrollbar showing behavior with the system
366    pub fn sync_scrollbar_appearance(cx: &mut App) {
367        let mode = if cx.should_auto_hide_scrollbars() {
368            ScrollbarMode::Scrolling
369        } else {
370            ScrollbarMode::Hover
371        };
372        Self::set_scrollbar_mode(mode, cx);
373    }
374
375    /// Changes the scrollbar display mode through [`Theme::update`], which
376    /// projects it onto the Base scrollbar and refreshes every window.
377    pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
378        Self::update(cx, |theme| theme.scrollbar_mode = mode);
379    }
380
381    /// Change the theme mode.
382    ///
383    /// Loads the registered theme for `mode` — even when `mode` is already
384    /// current, so a caller that swapped [`Theme::light_theme`] or
385    /// [`Theme::dark_theme`] sees the new theme — through [`Theme::update`],
386    /// which keeps every copy of the theme in step and refreshes every
387    /// window. `window` is accepted for compatibility; every window is
388    /// refreshed either way, so it is not read.
389    pub fn change(mode: impl Into<ThemeMode>, _window: Option<&mut Window>, cx: &mut App) {
390        let mode = mode.into();
391        if !cx.has_global::<Theme>() {
392            let mut theme = Theme::default();
393            theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
394            theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
395            cx.set_global(theme);
396        }
397
398        Self::edit(cx, true, |theme| theme.mode = mode);
399    }
400
401    /// This theme projected onto the Base layer, which owns the scrollbar and
402    /// resize handles and reads the semantic tokens.
403    fn base_theme(&self) -> gpui_base::Theme {
404        gpui_base::Theme {
405            appearance: if self.mode.is_dark() {
406                gpui_base::ThemeAppearance::Dark
407            } else {
408                gpui_base::ThemeAppearance::Light
409            },
410            tokens: self.semantic_tokens(),
411            scrollbar: gpui_base::ScrollbarTheme::new()
412                .with_mode(self.scrollbar_mode)
413                .with_motion(scrollbar_motion(self.scrollbar_mode))
414                .with_styles(
415                    gpui_base::ScrollbarStyles::default()
416                        .track(|style| style.bg(self.scrollbar))
417                        .track_hover(|style| style.bg(self.scrollbar))
418                        .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
419                        .thumb(|style| {
420                            style
421                                .bg(self.tokens.scrollbar_thumb)
422                                .radius(self.radius)
423                                .when(gpui_base::is_mobile(), |style| {
424                                    style
425                                        .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
426                                        .inset(MOBILE_SCROLLBAR_THUMB_INSET)
427                                        .radius(RADIUS_FULL)
428                                })
429                        })
430                        .thumb_hover(|style| {
431                            style
432                                .bg(self.tokens.scrollbar_thumb_hover)
433                                .radius(self.radius)
434                                .when(gpui_base::is_mobile(), |style| {
435                                    style
436                                        .width(SCROLLBAR_THUMB_HOVER_WIDTH)
437                                        .inset(SCROLLBAR_THUMB_INSET)
438                                })
439                        })
440                        .thumb_active(|style| {
441                            style
442                                .bg(self.tokens.scrollbar_thumb_hover)
443                                .radius(self.radius)
444                                .when(gpui_base::is_mobile(), |style| {
445                                    style
446                                        .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
447                                        .inset(SCROLLBAR_THUMB_INSET)
448                                })
449                        }),
450                ),
451            resizable: gpui_base::ResizableTheme {
452                handle: Some(self.border),
453                active_handle: Some(self.drag_border),
454            },
455            plot: gpui_base::PlotTheme::new().with_motion(plot_motion(&self.motion)),
456        }
457    }
458
459    /// Push the current theme down to the Base layer.
460    ///
461    /// The Base layer holds its own copy of the theme — the semantic tokens
462    /// plus the scrollbar and resize-handle styles — because it paints those
463    /// without going through `gpui-component`. [`Theme::change`] refreshes that
464    /// copy, but writing to the theme's public fields directly does not, so a
465    /// scrollbar keeps painting with the radius and colors it was last given.
466    ///
467    /// [`Theme::update`] and [`Theme::change`] call this after their edits.
468    /// After editing through [`Theme::global_mut`], call it yourself, then
469    /// refresh the windows.
470    ///
471    /// It rebuilds the Base theme from scratch, so any style written straight
472    /// onto the Base global is replaced. It does not touch [`Theme::tokens`].
473    pub fn sync_base(cx: &mut App) {
474        let theme = Theme::global(cx).clone();
475        let base_theme = theme.base_theme();
476        cx.set_global(base_theme);
477        crate::text::install_text_view_defaults(&theme, cx);
478    }
479
480    /// Get the input background color.
481    ///
482    /// For dark, use a transparent color mixed with the input border: `cx.theme().input`,
483    /// otherwise use the `cx.theme().background` color.
484    #[inline]
485    pub fn input_background(&self) -> Hsla {
486        if self.is_dark() {
487            self.input.mix_oklab(self.transparent, 0.3)
488        } else {
489            self.background
490        }
491    }
492
493    /// Get the editor background color, if not set, use the input background color.
494    #[inline]
495    pub(crate) fn editor_background(&self) -> Hsla {
496        self.highlight_theme
497            .style
498            .editor_background
499            .unwrap_or_else(|| self.input_background())
500    }
501
502    /// Returns a snapshot of the semantic design tokens represented by this
503    /// theme. The snapshot is computed from the legacy public fields so direct
504    /// mutations of those fields are reflected immediately.
505    pub fn semantic_tokens(&self) -> SemanticThemeTokens {
506        SemanticThemeTokens {
507            colors: self.color_tokens(),
508            radius: self.radius_tokens(),
509            spacing: self.spacing_tokens(),
510            typography: self.typography_tokens(),
511            shadow: self.shadow_tokens(),
512        }
513    }
514
515    /// Returns the styled layer's semantic motion policy.
516    pub fn motion_tokens(&self) -> &MotionTokens {
517        &self.motion
518    }
519
520    pub fn color_tokens(&self) -> ColorTokens {
521        ColorTokens {
522            background: self.background,
523            foreground: self.foreground,
524            surface: self.popover,
525            surface_foreground: self.popover_foreground,
526            primary: self.primary,
527            primary_foreground: self.primary_foreground,
528            secondary: self.secondary,
529            secondary_foreground: self.secondary_foreground,
530            muted: self.muted,
531            muted_foreground: self.muted_foreground,
532            accent: self.accent,
533            accent_foreground: self.accent_foreground,
534            destructive: self.danger,
535            destructive_foreground: self.danger_foreground,
536            border: self.border,
537            input: self.input,
538            ring: self.ring,
539            selection: self.selection,
540        }
541    }
542
543    /// The radius of a shape that reads as a circle or a pill — an avatar, a
544    /// slider thumb, a badge dot, a pill tab.
545    ///
546    /// A theme whose [`Theme::radius`] is zero squares these off too, so one
547    /// setting governs the whole UI instead of leaving a handful of
548    /// permanently round elements behind. Use it in place of
549    /// [`gpui::Styled::rounded_full`], or reach for
550    /// [`crate::ThemeStyled::rounded_full_style`] when styling an element.
551    pub fn radius_full(&self) -> Pixels {
552        if self.radius.is_zero() {
553            px(0.)
554        } else {
555            RADIUS_FULL
556        }
557    }
558
559    /// Returns the next surface radius above the existing `xl` theme tier.
560    ///
561    /// Larger surface tiers derive from the same application-controlled base
562    /// radius, so adjusting or squaring the theme updates every tier together.
563    pub fn radius_2xl(&self) -> Pixels {
564        self.radius * 2.5
565    }
566
567    /// Returns the surface radius above [`Self::radius_2xl`].
568    pub fn radius_3xl(&self) -> Pixels {
569        self.radius * 3.
570    }
571
572    /// Returns the surface radius above [`Self::radius_3xl`].
573    pub fn radius_4xl(&self) -> Pixels {
574        self.radius * 3.5
575    }
576
577    pub fn radius_tokens(&self) -> RadiusTokens {
578        RadiusTokens {
579            none: px(0.),
580            sm: self.radius / 2.,
581            md: self.radius,
582            lg: self.radius_lg,
583            xl: self.radius * 2.,
584            full: self.radius_full(),
585        }
586    }
587
588    pub fn spacing_tokens(&self) -> SpacingTokens {
589        SpacingTokens::default()
590    }
591
592    pub fn typography_tokens(&self) -> TypographyTokens {
593        let mut tokens = TypographyTokens::default();
594        tokens.sans = self.font_family.clone();
595        tokens.mono = self.mono_font_family.clone();
596        tokens.md.size = self.font_size;
597        tokens.mono_md.size = self.mono_font_size;
598        tokens
599    }
600
601    pub fn shadow_tokens(&self) -> ShadowTokens {
602        if self.shadow {
603            ShadowTokens::elevations(self.transparent.alpha(0.18))
604        } else {
605            ShadowTokens::default()
606        }
607    }
608
609    /// Applies the subset of semantic tokens representable by the legacy
610    /// theme. Scale-only spacing and elevation details have no legacy storage;
611    /// legacy components therefore keep their existing behavior.
612    pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
613        let colors = tokens.colors;
614        self.background = colors.background;
615        self.foreground = colors.foreground;
616        self.popover = colors.surface;
617        self.popover_foreground = colors.surface_foreground;
618        self.primary = colors.primary;
619        self.primary_foreground = colors.primary_foreground;
620        self.secondary = colors.secondary;
621        self.secondary_foreground = colors.secondary_foreground;
622        self.muted = colors.muted;
623        self.muted_foreground = colors.muted_foreground;
624        self.accent = colors.accent;
625        self.accent_foreground = colors.accent_foreground;
626        self.danger = colors.destructive;
627        self.danger_foreground = colors.destructive_foreground;
628        self.border = colors.border;
629        self.input = colors.input;
630        self.ring = colors.ring;
631
632        self.tokens.background = colors.background.into();
633        self.tokens.popover = colors.surface.into();
634        self.tokens.primary = colors.primary.into();
635        self.tokens.secondary = colors.secondary.into();
636        self.tokens.muted = colors.muted.into();
637        self.tokens.accent = colors.accent.into();
638        self.tokens.danger = colors.destructive.into();
639
640        self.radius = tokens.radius.md;
641        self.radius_lg = tokens.radius.lg;
642        self.font_family = tokens.typography.sans.clone();
643        self.mono_font_family = tokens.typography.mono.clone();
644        self.font_size = tokens.typography.md.size;
645        self.mono_font_size = tokens.typography.mono_md.size;
646        self.shadow = !tokens.shadow.sm.is_empty()
647            || !tokens.shadow.md.is_empty()
648            || !tokens.shadow.lg.is_empty();
649    }
650
651    /// Resolves a standalone semantic configuration over the current legacy
652    /// theme without mutating either value.
653    pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
654        let mut tokens = self.semantic_tokens();
655        config.apply_to(&mut tokens);
656        tokens
657    }
658
659    /// Applies the legacy-representable part of a standalone semantic config
660    /// and returns the complete resolved snapshot for application-owned UI.
661    pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
662        let tokens = self.resolve_semantic_config(config);
663        self.apply_semantic_tokens(&tokens);
664        tokens
665    }
666
667    /// Parses and applies a standalone `{ "tokens": ... }` semantic theme file.
668    pub fn apply_semantic_config_str(
669        &mut self,
670        content: &str,
671    ) -> anyhow::Result<SemanticThemeTokens> {
672        let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
673        Ok(self.apply_semantic_config(&config.tokens))
674    }
675}
676
677#[cfg(test)]
678mod semantic_token_tests {
679    use gpui::{Hsla, IsZero as _, px};
680
681    use super::{RADIUS_FULL, Theme};
682
683    #[test]
684    fn semantic_colors_are_a_live_projection_of_legacy_fields() {
685        let mut theme = Theme::default();
686        let primary = Hsla::default().alpha(0.42);
687        theme.primary = primary;
688
689        assert_eq!(theme.color_tokens().primary, primary);
690        assert_eq!(theme.semantic_tokens().colors.primary, primary);
691    }
692
693    #[test]
694    fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
695        let mut theme = Theme::default();
696        let component_color = theme.button_primary;
697        let mut tokens = theme.semantic_tokens();
698        tokens.colors.primary = Hsla::default().alpha(0.25);
699        tokens.colors.destructive = Hsla::default().alpha(0.75);
700        tokens.radius.md = px(10.);
701
702        theme.apply_semantic_tokens(&tokens);
703
704        assert_eq!(theme.primary, tokens.colors.primary);
705        assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
706        assert_eq!(theme.danger, tokens.colors.destructive);
707        assert_eq!(theme.radius, px(10.));
708        assert_eq!(theme.button_primary, component_color);
709    }
710
711    #[test]
712    fn square_themes_square_off_pills_and_circles() {
713        let mut theme = Theme::default();
714        assert_eq!(theme.radius_full(), RADIUS_FULL);
715        assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
716
717        // An application asking for square corners gets them everywhere, not
718        // just on the elements whose radius happens to come from `radius`.
719        theme.radius = px(0.);
720        assert_eq!(theme.radius_full(), px(0.));
721        assert_eq!(theme.radius_tokens().full, px(0.));
722    }
723
724    #[test]
725    fn larger_surface_radii_follow_the_theme_radius() {
726        let mut theme = Theme::default();
727        assert!(theme.radius_tokens().xl < theme.radius_2xl());
728        assert!(theme.radius_2xl() < theme.radius_3xl());
729        assert!(theme.radius_3xl() < theme.radius_4xl());
730
731        theme.radius = px(10.);
732        assert_eq!(theme.radius_2xl(), px(25.));
733        assert_eq!(theme.radius_3xl(), px(30.));
734        assert_eq!(theme.radius_4xl(), px(35.));
735
736        theme.radius = px(0.);
737        assert_eq!(theme.radius_2xl(), px(0.));
738        assert_eq!(theme.radius_3xl(), px(0.));
739        assert_eq!(theme.radius_4xl(), px(0.));
740    }
741
742    #[test]
743    fn base_projection_carries_a_square_radius_to_the_scrollbar() {
744        let mut theme = Theme::default();
745        assert!(!theme.base_theme().tokens.radius.md.is_zero());
746
747        // The scrollbar paints from the Base layer's copy of the theme, so a
748        // square theme has to reach it or the thumb stays a pill.
749        theme.radius = px(0.);
750        assert!(theme.base_theme().tokens.radius.md.is_zero());
751    }
752
753    #[test]
754    fn disabled_legacy_shadows_project_to_empty_elevations() {
755        let mut theme = Theme::default();
756        theme.shadow = false;
757
758        let shadows = theme.shadow_tokens();
759        assert!(shadows.sm.is_empty());
760        assert!(shadows.md.is_empty());
761        assert!(shadows.lg.is_empty());
762    }
763}
764
765impl From<&ThemeColor> for Theme {
766    fn from(colors: &ThemeColor) -> Self {
767        Theme {
768            mode: ThemeMode::default(),
769            transparent: Hsla::transparent_black(),
770            font_family: ".SystemUIFont".into(),
771            font_size: px(16.),
772            mono_font_family: mono_font::default_mono_font_family(),
773            mono_font_size: px(13.),
774            radius: px(6.),
775            radius_lg: px(8.),
776            shadow: true,
777            focus_ring: true,
778            scrollbar_mode: ScrollbarMode::default(),
779            notification: NotificationSettings::default(),
780            list: ListSettings::default(),
781            colors: *colors,
782            tokens: ThemeTokens::from(colors),
783            light_theme: Rc::new(ThemeConfig::default()),
784            dark_theme: Rc::new(ThemeConfig::default()),
785            highlight_theme: HighlightTheme::default_light(),
786            sheet: SheetSettings::default(),
787            motion: MotionTokens::default(),
788        }
789    }
790}
791
792#[derive(
793    Debug,
794    Clone,
795    Copy,
796    Default,
797    PartialEq,
798    PartialOrd,
799    Eq,
800    Ord,
801    Hash,
802    Serialize,
803    Deserialize,
804    JsonSchema,
805)]
806#[serde(rename_all = "snake_case")]
807pub enum ThemeMode {
808    #[default]
809    Light,
810    Dark,
811}
812
813impl ThemeMode {
814    #[inline(always)]
815    pub fn is_dark(&self) -> bool {
816        matches!(self, Self::Dark)
817    }
818
819    /// Return lower_case theme name: `light`, `dark`.
820    pub fn name(&self) -> &'static str {
821        match self {
822            ThemeMode::Light => "light",
823            ThemeMode::Dark => "dark",
824        }
825    }
826}
827
828impl From<WindowAppearance> for ThemeMode {
829    fn from(appearance: WindowAppearance) -> Self {
830        match appearance {
831            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
832            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
833        }
834    }
835}
836
837#[cfg(test)]
838mod update_tests {
839    use super::*;
840    use gpui::{TestAppContext, linear_color_stop, linear_gradient};
841
842    fn gradient(from: Hsla, to: Hsla) -> ThemeToken {
843        ThemeToken::new(
844            from,
845            linear_gradient(135., linear_color_stop(from, 0.), linear_color_stop(to, 1.)),
846        )
847    }
848
849    /// A color edited on `colors` reaches the token the components paint
850    /// with, and the Base projection the scrollbar paints with.
851    #[gpui::test]
852    fn editing_colors_updates_the_tokens_and_the_base_projection(cx: &mut TestAppContext) {
853        cx.update(|cx| {
854            init(cx);
855            let sidebar = gpui::rgb(0x123456).into();
856            let primary = gpui::rgb(0xabcdef).into();
857
858            Theme::update(cx, |theme| {
859                theme.sidebar = sidebar;
860                theme.colors.primary = primary;
861                theme.radius = px(0.);
862            });
863
864            let theme = Theme::global(cx);
865            assert_eq!(theme.tokens.sidebar.color, sidebar);
866            assert_eq!(theme.tokens.sidebar.background, sidebar.into());
867            assert_eq!(theme.tokens.primary.color, primary);
868            assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, primary);
869            assert!(gpui_base::Theme::global(cx).tokens.radius.md.is_zero());
870        });
871    }
872
873    /// Replacing the whole `colors` struct, as an application installing its
874    /// own palette does, rewrites every token.
875    #[gpui::test]
876    fn replacing_the_palette_rewrites_every_token(cx: &mut TestAppContext) {
877        cx.update(|cx| {
878            init(cx);
879            let palette = *ThemeColor::dark();
880
881            Theme::update(cx, |theme| theme.colors = palette);
882
883            assert_eq!(Theme::global(cx).tokens, ThemeTokens::from(palette));
884        });
885    }
886
887    /// A gradient a theme file gave a token survives edits to other fields;
888    /// editing that field's color replaces the gradient with the solid color.
889    #[gpui::test]
890    fn a_gradient_survives_until_its_own_color_is_edited(cx: &mut TestAppContext) {
891        cx.update(|cx| {
892            init(cx);
893            let from = gpui::rgb(0x4f46e5).into();
894            let to = gpui::rgb(0x06b6d4).into();
895            let token = gradient(from, to);
896            Theme::update(cx, |theme| theme.tokens.primary = token);
897            // The token's solid color is written back, so text painted with
898            // `theme.primary` matches the gradient's representative color.
899            assert_eq!(Theme::global(cx).primary, from);
900
901            Theme::update(cx, |theme| theme.secondary = gpui::rgb(0x222222).into());
902            assert_eq!(Theme::global(cx).tokens.primary, token);
903
904            let solid = gpui::rgb(0x999999).into();
905            Theme::update(cx, |theme| theme.primary = solid);
906            assert_eq!(Theme::global(cx).tokens.primary, solid.into());
907        });
908    }
909
910    /// Setting `mode` through `update` loads that mode's theme, the same as
911    /// `change`, and the Base projection follows.
912    #[gpui::test]
913    fn setting_the_mode_loads_that_modes_theme(cx: &mut TestAppContext) {
914        cx.update(|cx| {
915            init(cx);
916            let light_background = Theme::global(cx).background;
917
918            Theme::update(cx, |theme| theme.mode = ThemeMode::Dark);
919
920            let theme = Theme::global(cx);
921            assert!(theme.is_dark());
922            assert_ne!(theme.background, light_background);
923            assert_eq!(theme.tokens.background.color, theme.background);
924            assert_eq!(
925                gpui_base::Theme::global(cx).appearance,
926                gpui_base::ThemeAppearance::Dark
927            );
928            assert_eq!(
929                gpui_base::Theme::global(cx).tokens.colors.background,
930                theme.background
931            );
932        });
933    }
934
935    /// Applying a theme file through `update` keeps the gradients it
936    /// declares: the config sets `colors` and `tokens` to one color, which is
937    /// not a conflict to resolve.
938    #[gpui::test]
939    fn applying_a_config_keeps_its_gradients(cx: &mut TestAppContext) {
940        cx.update(|cx| {
941            init(cx);
942            let config: ThemeConfig = serde_json::from_value(serde_json::json!({
943                "name": "Gradient",
944                "mode": "light",
945                "colors": {
946                    "primary": "#4F46E5",
947                    "primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)"
948                }
949            }))
950            .unwrap();
951            let config = Rc::new(config);
952
953            Theme::update(cx, |theme| theme.apply_config(&config));
954
955            let theme = Theme::global(cx);
956            assert_eq!(theme.tokens.primary.color, theme.primary);
957            assert_ne!(
958                theme.tokens.primary.background,
959                theme.primary.into(),
960                "the gradient must survive the reconcile"
961            );
962        });
963    }
964
965    /// `apply_config` switches to the file's mode itself, so `edit` must not
966    /// load that mode's theme a second time over what the closure went on to
967    /// set — the same closure has to land the same result from either mode.
968    #[gpui::test]
969    fn edits_after_applying_a_config_of_the_other_mode_survive(cx: &mut TestAppContext) {
970        cx.update(|cx| {
971            init(cx);
972            let config: ThemeConfig = serde_json::from_value(serde_json::json!({
973                "name": "Rounded Dark",
974                "mode": "dark",
975                "radius": 12,
976                "colors": { "primary": "#4F46E5" }
977            }))
978            .unwrap();
979            let config = Rc::new(config);
980            assert!(!Theme::global(cx).is_dark());
981
982            let red = gpui::red();
983            Theme::update(cx, |theme| {
984                theme.apply_config(&config);
985                theme.radius = px(0.);
986                theme.colors.primary = red;
987            });
988
989            let theme = Theme::global(cx);
990            assert!(theme.is_dark());
991            assert!(Rc::ptr_eq(&theme.dark_theme, &config));
992            assert_eq!(theme.radius, px(0.), "the file's radius must not reload");
993            assert_eq!(theme.primary, red);
994            assert_eq!(theme.tokens.primary, red.into());
995            assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, red);
996        });
997    }
998
999    #[gpui::test]
1000    fn update_returns_the_closure_result(cx: &mut TestAppContext) {
1001        cx.update(|cx| {
1002            init(cx);
1003            let radius = Theme::update(cx, |theme| {
1004                theme.radius = px(6.);
1005                theme.radius
1006            });
1007            assert_eq!(radius, px(6.));
1008        });
1009    }
1010}
1011
1012#[cfg(test)]
1013mod base_theme_projection_tests {
1014    use super::*;
1015    use gpui::TestAppContext;
1016
1017    #[gpui::test]
1018    fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
1019        cx.update(|cx| {
1020            init(cx);
1021            assert_styled_projection(cx);
1022
1023            Theme::change(ThemeMode::Dark, None, cx);
1024            assert_styled_projection(cx);
1025
1026            Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
1027            assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
1028            assert_eq!(
1029                gpui_base::Theme::global(cx).scrollbar.mode(),
1030                gpui_base::ScrollbarMode::Always
1031            );
1032            assert_styled_projection(cx);
1033        });
1034    }
1035
1036    #[gpui::test]
1037    fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
1038        cx.update(|cx| {
1039            init(cx);
1040
1041            // Base itself ships none of this timing.
1042            let bare = gpui_base::ScrollbarMotion::default();
1043            assert_eq!(bare.enter(), Duration::ZERO);
1044            assert_eq!(bare.exit(), Duration::ZERO);
1045            assert_eq!(bare.expand(), Duration::ZERO);
1046
1047            Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
1048            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1049            assert_eq!(motion.idle(), SCROLLBAR_IDLE);
1050            assert_eq!(motion.enter(), SCROLLBAR_ENTER);
1051            assert_eq!(motion.exit(), SCROLLBAR_EXIT);
1052            assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
1053            assert_eq!(
1054                motion.entrance(),
1055                gpui_base::ScrollbarEntrance::Fade,
1056                "scroll-revealed scrollbars fade in without sliding"
1057            );
1058
1059            Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
1060            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1061            assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
1062            assert_eq!(
1063                motion.thumb_hover_entrance(),
1064                gpui_base::ScrollbarEntrance::SlideAndFade
1065            );
1066        });
1067    }
1068
1069    #[test]
1070    fn default_motion_tokens_form_a_coherent_semantic_scale() {
1071        let theme = Theme::default();
1072        let motion = theme.motion_tokens();
1073
1074        assert_eq!(motion.duration_instant, Duration::ZERO);
1075        assert!(motion.duration_fast < motion.duration_normal);
1076        assert!(motion.duration_normal < motion.duration_slow);
1077        assert!(motion.distance_short.0 < motion.distance_medium.0);
1078        assert_eq!(motion.easing_enter.sample(0.0), 0.0);
1079        assert_eq!(motion.easing_enter.sample(1.0), 1.0);
1080    }
1081
1082    fn assert_styled_projection(cx: &App) {
1083        let theme = Theme::global(cx);
1084        let base = gpui_base::Theme::global(cx);
1085
1086        assert_eq!(base.tokens, theme.semantic_tokens());
1087        assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
1088        assert_eq!(
1089            base.scrollbar.motion(),
1090            scrollbar_motion(theme.scrollbar_mode)
1091        );
1092        assert_eq!(base.resizable.handle, Some(theme.border));
1093        assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
1094    }
1095
1096    #[gpui::test]
1097    fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
1098        fn assert_close(left: ColorTokens, right: ColorTokens) {
1099            macro_rules! color {
1100                ($field:ident) => {
1101                    assert!(
1102                        (left.$field.h - right.$field.h).abs() < 1e-6
1103                            && (left.$field.s - right.$field.s).abs() < 1e-6
1104                            && (left.$field.l - right.$field.l).abs() < 1e-6
1105                            && (left.$field.a - right.$field.a).abs() < 1e-6,
1106                        "{} differs: {:?} != {:?}",
1107                        stringify!($field),
1108                        left.$field,
1109                        right.$field
1110                    );
1111                };
1112            }
1113            color!(background);
1114            color!(foreground);
1115            color!(surface);
1116            color!(surface_foreground);
1117            color!(primary);
1118            color!(primary_foreground);
1119            color!(secondary);
1120            color!(secondary_foreground);
1121            color!(muted);
1122            color!(muted_foreground);
1123            color!(accent);
1124            color!(accent_foreground);
1125            color!(destructive);
1126            color!(destructive_foreground);
1127            color!(border);
1128            color!(input);
1129            color!(ring);
1130            color!(selection);
1131        }
1132
1133        cx.update(crate::init);
1134        cx.update(|cx| {
1135            assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
1136        });
1137
1138        cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
1139        cx.update(|cx| {
1140            assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
1141        });
1142    }
1143}