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