Skip to main content

gpui_cn/theme/
mod.rs

1//! The theme: a small config per appearance, the tokens derived from it,
2//! and the preferences that shape motion and cursors.
3
4mod color;
5mod config;
6pub mod fonts;
7mod motion;
8mod scope;
9mod tokens;
10
11pub use color::{contrast_ratio, hex, lightness, mix, to_hex, try_hex};
12pub use config::{SemanticColors, ThemeConfig, ThemeFonts};
13pub use motion::MotionTokens;
14pub use scope::ThemeScope;
15pub use tokens::{MetricTokens, ThemeTokens};
16
17use gpui_kit::{
18    App, BorrowAppContext as _, Global, Pixels, SharedString, Window, WindowAppearance,
19    base::{self, ThemeAppearance},
20    px,
21};
22
23use tokens::Metrics;
24
25/// Which appearance the theme follows.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ThemeMode {
28    /// Follow the operating system.
29    #[default]
30    System,
31    /// Always the light config.
32    Light,
33    /// Always the dark config.
34    Dark,
35}
36
37/// Whether motion is reduced.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum ReduceMotion {
40    /// Follow the operating system's accessibility setting.
41    #[default]
42    System,
43    /// Reduce motion regardless of the system.
44    On,
45    /// Animate regardless of the system.
46    Off,
47}
48
49/// The gpui-cn theme, a GPUI global.
50///
51/// Public fields are the inputs. Edit them through [`Theme::update`], which
52/// re-derives the tokens, projects them onto `gpui_base::Theme`, applies the
53/// motion preference, and refreshes every window. Components read the
54/// derived [`ThemeTokens`] through [`ActiveTheme::theme`].
55///
56/// gpui-cn owns `gpui_base::Theme` the same way `gpui-component` does:
57/// every change is projected onto it, and nothing reads it back.
58#[non_exhaustive]
59pub struct Theme {
60    /// Which config is active.
61    pub mode: ThemeMode,
62    /// The config used in light appearance.
63    pub light: ThemeConfig,
64    /// The config used in dark appearance.
65    pub dark: ThemeConfig,
66    /// The base corner radius. The radius scale derives from it.
67    pub radius: Pixels,
68    /// The interface font size. It is also the window rem size, so it is
69    /// the zoom control for everything on the rem scale.
70    pub ui_font_size: Pixels,
71    /// The code font size.
72    pub code_font_size: Pixels,
73    /// Whether motion is reduced.
74    pub reduce_motion: ReduceMotion,
75    /// Durations and curves components animate with.
76    pub motion: MotionTokens,
77    /// Pointer cursor on interactive controls. Off keeps the platform arrow
78    /// on buttons. Links always use the pointer.
79    pub pointer_cursors: bool,
80    /// Whether elevated surfaces cast shadows.
81    pub shadow: bool,
82    /// Whether a finger is the pointer. On by default on iOS and Android.
83    /// Controls and rows grow to the 44pt hit target of Apple's Human
84    /// Interface Guidelines, text takes its body size, and nothing shows a
85    /// hover state, since a finger cannot hover.
86    pub touch: bool,
87
88    system_appearance: ThemeAppearance,
89    tokens: ThemeTokens,
90    /// Token sets for parts of a window drawn on their own surface, keyed
91    /// by name. See [`ThemeScope`].
92    scopes: Vec<Scope>,
93    /// The scope the element being drawn is inside, an index into
94    /// `scopes`. A `Cell`, so entering a scope changes no global and wakes
95    /// no observer of the theme.
96    active_scope: std::cell::Cell<Option<usize>>,
97    /// What `App::reduce_motion` held before gpui-cn first overrode it, so
98    /// `ReduceMotion::System` can hand the flag back to base.
99    reduce_motion_before_override: Option<bool>,
100}
101
102impl Global for Theme {}
103
104/// A named token set; see [`Theme::set_scope`].
105struct Scope {
106    key: SharedString,
107    config: ThemeConfig,
108    tokens: ThemeTokens,
109}
110
111impl Theme {
112    /// Installs the theme and projects it onto `gpui_base::Theme`.
113    pub(crate) fn init(cx: &mut App) {
114        let mut theme = Self::new(appearance_of(cx.window_appearance()));
115        theme.resolve(cx);
116        cx.set_global(theme);
117    }
118
119    fn new(system_appearance: ThemeAppearance) -> Self {
120        let light = ThemeConfig::light();
121        let metrics = Metrics {
122            radius: px(10.),
123            ui_font_size: px(16.),
124            code_font_size: px(13.),
125            touch: base::is_mobile(),
126        };
127        Self {
128            mode: ThemeMode::System,
129            tokens: ThemeTokens::derive(&light, ThemeAppearance::Light, metrics),
130            light,
131            dark: ThemeConfig::dark(),
132            radius: metrics.radius,
133            ui_font_size: metrics.ui_font_size,
134            code_font_size: metrics.code_font_size,
135            reduce_motion: ReduceMotion::System,
136            motion: MotionTokens::default(),
137            pointer_cursors: false,
138            shadow: true,
139            touch: metrics.touch,
140            system_appearance,
141            reduce_motion_before_override: None,
142            scopes: Vec::new(),
143            active_scope: std::cell::Cell::new(None),
144        }
145    }
146
147    /// The theme global. Panics before [`crate::init`].
148    pub fn global(cx: &App) -> &Self {
149        cx.global::<Self>()
150    }
151
152    /// Whether a looping animation (a spinner, a pulse, a sweep) holds
153    /// still: under reduced motion, or with every motion duration at zero.
154    pub fn holds_still(cx: &App) -> bool {
155        cx.reduce_motion() || Self::global(cx).motion.slow.is_zero()
156    }
157
158    /// The derived tokens for the active appearance.
159    pub fn tokens(&self) -> &ThemeTokens {
160        &self.tokens
161    }
162
163    /// The tokens a component reads: the scope's while it draws inside a
164    /// [`ThemeScope`], the active appearance's otherwise.
165    pub fn active_tokens(&self) -> &ThemeTokens {
166        self.active_scope
167            .get()
168            .and_then(|index| self.scopes.get(index))
169            .map_or(&self.tokens, |scope| &scope.tokens)
170    }
171
172    /// Registers or replaces the scope `key`: a part of the window drawn on
173    /// its own surface and ink, such as a terminal with its own colors.
174    /// Its tokens derive from `config` with this theme's sizes, and its
175    /// appearance follows the surface: dark when the surface's OKLab
176    /// lightness is under one half. They derive again whenever the theme
177    /// changes, so a font size change reaches them too.
178    pub fn set_scope(cx: &mut App, key: impl Into<SharedString>, config: ThemeConfig) {
179        let key = key.into();
180        Self::update(cx, |theme| {
181            if let Some(scope) = theme.scopes.iter_mut().find(|scope| scope.key == key) {
182                scope.config = config;
183            } else {
184                theme.scopes.push(Scope {
185                    key,
186                    tokens: theme.tokens.clone(),
187                    config,
188                });
189            }
190        });
191    }
192
193    /// The tokens of the scope `key`, if it is registered.
194    pub fn scope(&self, key: &str) -> Option<&ThemeTokens> {
195        self.scopes
196            .iter()
197            .find(|scope| scope.key.as_ref() == key)
198            .map(|scope| &scope.tokens)
199    }
200
201    /// The scope `key`'s index, for [`ThemeScope`].
202    fn scope_index(&self, key: &str) -> Option<usize> {
203        self.scopes
204            .iter()
205            .position(|scope| scope.key.as_ref() == key)
206    }
207
208    /// The active appearance after resolving `mode`.
209    pub fn appearance(&self) -> ThemeAppearance {
210        match self.mode {
211            ThemeMode::System => self.system_appearance,
212            ThemeMode::Light => ThemeAppearance::Light,
213            ThemeMode::Dark => ThemeAppearance::Dark,
214        }
215    }
216
217    /// Whether the active appearance is dark.
218    pub fn is_dark(&self) -> bool {
219        self.appearance() == ThemeAppearance::Dark
220    }
221
222    /// The config for the active appearance.
223    pub fn active_config(&self) -> &ThemeConfig {
224        if self.is_dark() {
225            &self.dark
226        } else {
227            &self.light
228        }
229    }
230
231    /// Edits the theme, then re-derives tokens, projects them onto base,
232    /// applies the motion preference, and refreshes every window.
233    pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
234        let result = cx.update_global::<Self, _>(|theme, cx| {
235            let result = edit(theme);
236            theme.resolve(cx);
237            result
238        });
239        cx.refresh_windows();
240        result
241    }
242
243    /// Switches the mode.
244    pub fn change(mode: ThemeMode, cx: &mut App) {
245        Self::update(cx, |theme| theme.mode = mode);
246    }
247
248    /// Reads the system appearance again. gpui-cn's root layer calls this when the
249    /// window appearance changes; it matters only in [`ThemeMode::System`].
250    pub fn sync_system_appearance(window: Option<&Window>, cx: &mut App) {
251        let appearance = window
252            .map(|window| window.appearance())
253            .unwrap_or_else(|| cx.window_appearance());
254        let appearance = appearance_of(appearance);
255        if Self::global(cx).system_appearance == appearance {
256            return;
257        }
258        Self::update(cx, |theme| theme.system_appearance = appearance);
259    }
260
261    /// Sets both font sizes at once, keeping their ratio.
262    pub fn set_ui_font_size(cx: &mut App, size: Pixels) {
263        Self::update(cx, |theme| {
264            let ratio = f32::from(theme.code_font_size) / f32::from(theme.ui_font_size);
265            theme.ui_font_size = size;
266            theme.code_font_size = size * ratio;
267        });
268    }
269
270    /// Re-derives tokens, projects them onto base, and applies the motion
271    /// preference.
272    fn resolve(&mut self, cx: &mut App) {
273        let metrics = Metrics {
274            radius: self.radius,
275            ui_font_size: self.ui_font_size,
276            code_font_size: self.code_font_size,
277            touch: self.touch,
278        };
279        self.tokens = ThemeTokens::derive(self.active_config(), self.appearance(), metrics);
280        for scope in &mut self.scopes {
281            let appearance = if lightness(scope.config.surface) < 0.5 {
282                ThemeAppearance::Dark
283            } else {
284                ThemeAppearance::Light
285            };
286            scope.tokens = ThemeTokens::derive(&scope.config, appearance, metrics);
287        }
288        self.sync_base(cx);
289        self.apply_reduce_motion(cx);
290    }
291
292    /// Writes the derived tokens into `gpui_base::Theme`, with the
293    /// scrollbar and resize-handle styles every base element paints with.
294    ///
295    /// The scrollbar is a macOS overlay scroller: a rounded thumb that
296    /// shows while the region scrolls and fades when it rests, wider under
297    /// the pointer. Its colors stay base's, which take the foreground at
298    /// an alpha, so they follow the appearance.
299    fn sync_base(&self, cx: &mut App) {
300        let metrics = &self.tokens.metrics;
301        let radius = self.tokens.radius_full();
302        let thumb = |width: Pixels| {
303            move |style: base::ScrollbarThumbStyle| {
304                style
305                    .width(width)
306                    .inset(metrics.scrollbar_inset)
307                    .radius(radius)
308            }
309        };
310        let scrollbar = base::ScrollbarTheme::new()
311            .with_mode(base::ScrollbarMode::Scrolling)
312            .with_styles(
313                base::ScrollbarStyles::default()
314                    .thumb(thumb(metrics.scrollbar_thumb))
315                    .thumb_hover(thumb(metrics.scrollbar_thumb_active))
316                    .thumb_active(thumb(metrics.scrollbar_thumb_active)),
317            );
318        let base_theme = base::Theme::global_mut(cx);
319        base_theme.appearance = self.tokens.appearance;
320        base_theme.tokens = self.tokens.base.clone();
321        base_theme.scrollbar = scrollbar;
322        base_theme.resizable = base::ResizableTheme {
323            handle: Some(self.tokens.border()),
324            active_handle: Some(self.tokens.ring()),
325        };
326    }
327
328    fn apply_reduce_motion(&mut self, cx: &mut App) {
329        match self.reduce_motion {
330            ReduceMotion::System => {
331                if let Some(previous) = self.reduce_motion_before_override.take() {
332                    cx.set_reduce_motion(previous);
333                    base::apply_system_reduce_motion(cx);
334                }
335            }
336            ReduceMotion::On | ReduceMotion::Off => {
337                if self.reduce_motion_before_override.is_none() {
338                    self.reduce_motion_before_override = Some(cx.reduce_motion());
339                }
340                cx.set_reduce_motion(self.reduce_motion == ReduceMotion::On);
341            }
342        }
343    }
344}
345
346/// Derived tokens for unit tests in other modules.
347#[cfg(test)]
348pub(crate) fn test_tokens(config: &ThemeConfig, appearance: ThemeAppearance) -> ThemeTokens {
349    ThemeTokens::derive(
350        config,
351        appearance,
352        Metrics {
353            radius: px(10.),
354            ui_font_size: px(16.),
355            code_font_size: px(13.),
356            touch: false,
357        },
358    )
359}
360
361fn appearance_of(appearance: WindowAppearance) -> ThemeAppearance {
362    match appearance {
363        WindowAppearance::Dark | WindowAppearance::VibrantDark => ThemeAppearance::Dark,
364        WindowAppearance::Light | WindowAppearance::VibrantLight => ThemeAppearance::Light,
365    }
366}
367
368/// Access to the derived theme tokens from a context.
369pub trait ActiveTheme {
370    /// The tokens for the active appearance.
371    fn theme(&self) -> &ThemeTokens;
372}
373
374impl ActiveTheme for App {
375    #[inline(always)]
376    fn theme(&self) -> &ThemeTokens {
377        Theme::global(self).active_tokens()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use gpui_kit::TestAppContext;
385
386    #[gpui_kit::test]
387    fn init_owns_base_and_projects_tokens(cx: &mut TestAppContext) {
388        cx.update(|cx| {
389            gpui_kit::init(cx);
390            crate::init(cx);
391            let theme = Theme::global(cx);
392            assert_eq!(theme.mode, ThemeMode::System);
393            let base_theme = base::Theme::global(cx);
394            assert_eq!(base_theme.tokens.colors, theme.tokens().base.colors);
395            assert_eq!(base_theme.appearance, theme.appearance());
396            assert_eq!(base_theme.tokens.radius.lg, px(10.));
397        });
398    }
399
400    #[gpui_kit::test]
401    fn change_switches_tokens_and_base(cx: &mut TestAppContext) {
402        cx.update(|cx| {
403            gpui_kit::init(cx);
404            crate::init(cx);
405            Theme::change(ThemeMode::Dark, cx);
406            assert!(cx.theme().is_dark());
407            assert_eq!(cx.theme().background(), hex("#181818"));
408            assert_eq!(base::Theme::global(cx).appearance, ThemeAppearance::Dark);
409            Theme::change(ThemeMode::Light, cx);
410            assert_eq!(cx.theme().background(), hex("#ffffff"));
411            assert_eq!(base::Theme::global(cx).appearance, ThemeAppearance::Light);
412        });
413    }
414
415    #[gpui_kit::test]
416    fn update_re_derives_from_edited_inputs(cx: &mut TestAppContext) {
417        cx.update(|cx| {
418            gpui_kit::init(cx);
419            crate::init(cx);
420            Theme::update(cx, |theme| {
421                theme.mode = ThemeMode::Light;
422                theme.light.accent = hex("#ff0000");
423                theme.radius = px(5.);
424            });
425            assert_eq!(cx.theme().ring(), hex("#ff0000"));
426            assert_eq!(cx.theme().radius_md(), px(4.));
427            assert_eq!(base::Theme::global(cx).tokens.colors.ring, hex("#ff0000"));
428        });
429    }
430
431    #[gpui_kit::test]
432    fn font_size_scales_typography_together(cx: &mut TestAppContext) {
433        cx.update(|cx| {
434            gpui_kit::init(cx);
435            crate::init(cx);
436            Theme::set_ui_font_size(cx, px(20.));
437            let theme = Theme::global(cx);
438            assert_eq!(theme.ui_font_size, px(20.));
439            assert_eq!(theme.code_font_size, px(16.25));
440            assert_eq!(cx.theme().base.typography.md.size, px(20.));
441            assert_eq!(base::Theme::global(cx).tokens.typography.md.size, px(20.));
442        });
443    }
444
445    #[gpui_kit::test]
446    fn reduce_motion_preference_overrides_and_hands_back(cx: &mut TestAppContext) {
447        cx.update(|cx| {
448            gpui_kit::init(cx);
449            crate::init(cx);
450            assert!(!cx.reduce_motion());
451            Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::On);
452            assert!(cx.reduce_motion());
453            Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::Off);
454            assert!(!cx.reduce_motion());
455            Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::On);
456            assert!(cx.reduce_motion());
457            Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::System);
458            // Under the test scheduler base does not consult the platform, so
459            // the flag returns to what it held before the first override.
460            assert!(!cx.reduce_motion());
461        });
462    }
463}