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