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