Skip to main content

gpui_macos/
theme.rs

1//! macOS semantic colors, typography, geometry, and material preferences.
2use gpui_kit::base::motion::{Easing, Transition};
3use gpui_kit::base::{ColorTokens, PlotMotion, PlotTheme, RadiusTokens, Spring, ThemeAppearance};
4use gpui_kit::{App, BoxShadow, Global, Hsla, SharedString, WindowAppearance, px, rgb};
5use std::time::Duration;
6
7/// Shared presentation for native adapters and the complete Kit facade.
8#[derive(Clone, Debug, PartialEq)]
9pub struct Theme {
10    pub name: SharedString,
11    pub appearance: ThemeAppearance,
12    pub background: Hsla,
13    pub surface: Hsla,
14    pub inset: Hsla,
15    pub foreground: Hsla,
16    pub secondary: Hsla,
17    pub bright: Hsla,
18    pub accent: Hsla,
19    pub on_accent: Hsla,
20    pub selection: Hsla,
21    pub border: Hsla,
22    pub danger: Hsla,
23    pub warning: Hsla,
24    pub success: Hsla,
25    pub chart: [Hsla; 5],
26    pub font: SharedString,
27    pub mono_font: SharedString,
28    /// Material tint from clear (0) to fully tinted (1). Default: 0.65.
29    pub glass_tint: f32,
30    /// Opaque surfaces for Reduce transparency.
31    pub reduce_transparency: bool,
32    /// Stronger borders and separators for Increase contrast.
33    pub increased_contrast: bool,
34}
35impl Global for Theme {}
36impl Default for Theme {
37    fn default() -> Self {
38        Self::light()
39    }
40}
41
42impl Theme {
43    pub fn light() -> Self {
44        Self {
45            name: "macOS Light".into(),
46            appearance: ThemeAppearance::Light,
47            background: rgb(0xffffff).into(),
48            surface: rgb(0xffffff).into(),
49            inset: rgb(0xf7f7f7).into(),
50            foreground: Hsla::black().opacity(216. / 255.),
51            secondary: Hsla::black().opacity(127. / 255.),
52            bright: rgb(0).into(),
53            accent: rgb(0x007aff).into(),
54            on_accent: rgb(0xffffff).into(),
55            selection: rgb(0xb3d7ff).into(),
56            border: Hsla::from(rgb(0)).opacity(0.12),
57            danger: rgb(0xff3b30).into(),
58            warning: rgb(0xa46100).into(),
59            success: rgb(0x34c759).into(),
60            chart: [
61                rgb(0x007aff).into(),
62                rgb(0xaf52de).into(),
63                rgb(0x30b0c7).into(),
64                rgb(0xff9500).into(),
65                rgb(0x34c759).into(),
66            ],
67            font: ".SystemUIFont".into(),
68            mono_font: default_mono_font(),
69            glass_tint: 0.65,
70            reduce_transparency: false,
71            increased_contrast: false,
72        }
73    }
74    pub fn dark() -> Self {
75        Self {
76            name: "macOS Dark".into(),
77            appearance: ThemeAppearance::Dark,
78            background: rgb(0x1e1e1e).into(),
79            surface: rgb(0x1e1e1e).into(),
80            inset: rgb(0x262626).into(),
81            foreground: Hsla::white().opacity(216. / 255.),
82            secondary: Hsla::white().opacity(140. / 255.),
83            bright: rgb(0xffffff).into(),
84            accent: rgb(0x007aff).into(),
85            on_accent: rgb(0xffffff).into(),
86            selection: rgb(0x3f638b).into(),
87            border: Hsla::from(rgb(0xffffff)).opacity(0.13),
88            danger: rgb(0xff453a).into(),
89            warning: rgb(0xffd60a).into(),
90            success: rgb(0x30d158).into(),
91            chart: [
92                rgb(0x007aff).into(),
93                rgb(0xbf5af2).into(),
94                rgb(0x64d2ff).into(),
95                rgb(0xff9f0a).into(),
96                rgb(0x30d158).into(),
97            ],
98            font: ".SystemUIFont".into(),
99            mono_font: default_mono_font(),
100            glass_tint: 0.65,
101            reduce_transparency: false,
102            increased_contrast: false,
103        }
104    }
105    pub fn from_appearance(appearance: WindowAppearance) -> Self {
106        let mut theme = match appearance {
107            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::dark(),
108            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
109        };
110        theme.read_accessibility();
111        theme
112    }
113    /// Snapshot without changing whether the application follows the system.
114    pub fn system(cx: &App) -> Self {
115        Self::from_appearance(cx.window_appearance())
116    }
117    pub fn is_dark(&self) -> bool {
118        self.appearance == ThemeAppearance::Dark
119    }
120    pub fn with_accent(mut self, accent: Hsla) -> Self {
121        self.accent = accent;
122        self.selection = accent.opacity(if self.is_dark() { 0.32 } else { 0.3 });
123        self.chart[0] = accent;
124        self
125    }
126    pub fn with_glass_tint(mut self, tint: f32) -> Self {
127        self.glass_tint = if tint.is_finite() {
128            tint.clamp(0., 1.)
129        } else {
130            0.65
131        };
132        self
133    }
134    pub fn with_reduce_transparency(mut self, reduce: bool) -> Self {
135        self.reduce_transparency = reduce;
136        self
137    }
138    pub fn with_increased_contrast(mut self, increase: bool) -> Self {
139        self.increased_contrast = increase;
140        self
141    }
142    pub fn normal_fill(&self) -> Hsla {
143        self.foreground.opacity(0.04)
144    }
145    pub fn hover_fill(&self) -> Hsla {
146        self.foreground.opacity(0.07)
147    }
148    pub fn selected_fill(&self) -> Hsla {
149        self.accent
150            .opacity(if self.is_dark() { 0.25 } else { 0.12 })
151    }
152    pub fn pressed_fill(&self) -> Hsla {
153        self.foreground.opacity(0.12)
154    }
155    pub fn control_fill(&self) -> Hsla {
156        // A neutral bezel is distinct from the text editor's white/dark well.
157        // AppKit resolves these roles independently, even in the same group.
158        self.background.blend(self.foreground.opacity(0.11))
159    }
160    pub fn control_border(&self) -> Hsla {
161        self.foreground.opacity(if self.increased_contrast {
162            0.55
163        } else if self.is_dark() {
164            0.1
165        } else {
166            0.08
167        })
168    }
169    pub fn focus_border(&self) -> Hsla {
170        self.accent
171    }
172    pub fn divider(&self) -> Hsla {
173        self.foreground.opacity(if self.increased_contrast {
174            0.4
175        } else {
176            25. / 255.
177        })
178    }
179    /// Translucent fill over GPUI's native blurred window backdrop.
180    /// An approximation of the material; it does not implement Apple's refraction shader.
181    pub fn glass_fill(&self) -> Hsla {
182        if self.reduce_transparency {
183            self.surface
184        } else {
185            self.surface
186                .opacity(0.42 + 0.5 * self.glass_tint.clamp(0., 1.))
187        }
188    }
189    pub fn glass_border(&self) -> Hsla {
190        if self.increased_contrast {
191            self.control_border()
192        } else if self.is_dark() {
193            Hsla::from(rgb(0xffffff)).opacity(0.18)
194        } else {
195            Hsla::from(rgb(0)).opacity(0.1)
196        }
197    }
198    pub fn control_shadow(&self) -> Vec<BoxShadow> {
199        vec![
200            BoxShadow::new(
201                px(0.),
202                px(1.),
203                Hsla::from(rgb(0)).opacity(if self.is_dark() { 0.2 } else { 0.08 }),
204            )
205            .blur_radius(px(2.)),
206        ]
207    }
208    pub fn focus_shadow(&self) -> Vec<BoxShadow> {
209        vec![BoxShadow::new(px(0.), px(0.), self.accent.opacity(0.35)).spread_radius(px(3.))]
210    }
211    pub fn popover_shadow(&self) -> Vec<BoxShadow> {
212        vec![
213            BoxShadow::new(
214                px(0.),
215                px(8.),
216                Hsla::from(rgb(0)).opacity(if self.is_dark() { 0.4 } else { 0.16 }),
217            )
218            .blur_radius(px(28.)),
219            BoxShadow::new(px(0.), px(1.), Hsla::from(rgb(0)).opacity(0.09)).blur_radius(px(4.)),
220        ]
221    }
222    pub fn input_style(&self) -> gpui_kit::base::input::InputEditorStyle {
223        gpui_kit::base::input::InputEditorStyle {
224            foreground: self.foreground,
225            muted_foreground: self.secondary,
226            background: self.surface,
227            border: self.control_border(),
228            selection: self.selection,
229            caret: self.accent,
230            ..Default::default()
231        }
232    }
233    pub fn tokens(&self) -> ColorTokens {
234        ColorTokens {
235            background: self.background,
236            foreground: self.foreground,
237            surface: self.surface,
238            surface_foreground: self.foreground,
239            primary: self.accent,
240            primary_foreground: self.on_accent,
241            secondary: self.surface,
242            secondary_foreground: self.foreground,
243            muted: self.inset,
244            muted_foreground: self.secondary,
245            accent: self.selected_fill(),
246            accent_foreground: self.foreground,
247            destructive: self.danger,
248            destructive_foreground: self.on_accent,
249            border: self.control_border(),
250            input: self.control_border(),
251            ring: self.accent,
252            selection: self.selection,
253        }
254    }
255    /// Apply explicit appearance and stop following the system.
256    pub fn apply(self, cx: &mut App) {
257        crate::system_theme::stop_following(cx);
258        cx.set_window_appearance(Some(if self.is_dark() {
259            WindowAppearance::Dark
260        } else {
261            WindowAppearance::Light
262        }));
263        self.apply_palette(cx);
264    }
265    pub(crate) fn read_accessibility(&mut self) {
266        #[cfg(target_os = "macos")]
267        {
268            let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
269            self.reduce_transparency = workspace.accessibilityDisplayShouldReduceTransparency();
270            self.increased_contrast = workspace.accessibilityDisplayShouldIncreaseContrast();
271        }
272    }
273    pub(crate) fn apply_palette(self, cx: &mut App) {
274        self.sync_kit(cx);
275        let base = gpui_kit::base::Theme::global_mut(cx);
276        base.appearance = self.appearance;
277        base.tokens.colors = self.tokens();
278        base.tokens.radius = RadiusTokens {
279            none: px(0.),
280            sm: px(4.),
281            md: px(6.),
282            lg: px(10.),
283            xl: px(16.),
284            full: px(9999.),
285        };
286        base.tokens.shadow.sm = self.control_shadow();
287        base.tokens.shadow.md = self.popover_shadow();
288        base.tokens.shadow.lg = self.popover_shadow();
289        let type_scale = &mut base.tokens.typography;
290        type_scale.sans = self.font.clone();
291        type_scale.mono = self.mono_font.clone();
292        for (token, size) in [
293            (&mut type_scale.xs, 10.),
294            (&mut type_scale.sm, 11.),
295            (&mut type_scale.md, 13.),
296            (&mut type_scale.lg, 15.),
297            (&mut type_scale.xl, 20.),
298            (&mut type_scale.mono_md, 12.),
299        ] {
300            token.size = px(size);
301            token.line_height = px(size * 1.4);
302        }
303        let duration = Duration::from_millis(180);
304        base.plot = PlotTheme::new().with_motion(
305            PlotMotion::default()
306                .with_pointer(Spring::new(duration).with_epsilon(0.1))
307                .with_enter(
308                    Transition::new(duration)
309                        .easing(Easing::cubic_bezier(0.16, 1., 0.3, 1.).expect("static curve")),
310                )
311                .with_exit(Transition::new(Duration::from_millis(120))),
312        );
313        cx.set_global(self);
314        cx.refresh_windows();
315    }
316    fn sync_kit(&self, cx: &mut App) {
317        use gpui_kit::component::theme::{Theme as KitTheme, ThemeMode};
318        let mode = if self.is_dark() {
319            ThemeMode::Dark
320        } else {
321            ThemeMode::Light
322        };
323        if KitTheme::global(cx).mode != mode {
324            KitTheme::change(mode, None, cx);
325        }
326        KitTheme::update(cx, |kit| {
327            kit.font_family = self.font.clone();
328            kit.mono_font_family = self.mono_font.clone();
329            // Kit uses this value as Window::rem_size, rather than a label size.
330            kit.font_size = px(16.);
331            kit.mono_font_size = px(12.);
332            kit.radius = px(6.);
333            kit.radius_lg = px(16.);
334            kit.shadow = true;
335            kit.focus_ring = true;
336            let c = &mut kit.colors;
337            c.background = self.background;
338            c.foreground = self.foreground;
339            c.border = self.control_border();
340            c.input = self.control_border();
341            c.accent = self.selected_fill();
342            c.accent_foreground = self.foreground;
343            c.primary = self.accent;
344            c.primary_foreground = self.on_accent;
345            c.primary_hover = self.accent.opacity(0.85);
346            c.primary_active = self.accent.opacity(0.7);
347            c.secondary = self.control_fill();
348            c.secondary_foreground = self.foreground;
349            c.secondary_hover = self.hover_fill();
350            c.secondary_active = self.pressed_fill();
351            c.button = self.control_fill();
352            c.button_foreground = self.foreground;
353            c.button_hover = self.hover_fill();
354            c.button_active = self.pressed_fill();
355            c.button_primary = c.primary;
356            c.button_primary_foreground = c.primary_foreground;
357            c.button_primary_hover = c.primary_hover;
358            c.button_primary_active = c.primary_active;
359            c.button_secondary = c.secondary;
360            c.button_secondary_foreground = c.secondary_foreground;
361            c.button_secondary_hover = c.secondary_hover;
362            c.button_secondary_active = c.secondary_active;
363            c.danger = self.danger;
364            c.danger_foreground = self.on_accent;
365            c.danger_hover = self.danger.opacity(0.85);
366            c.danger_active = self.danger.opacity(0.7);
367            c.button_danger = c.danger;
368            c.button_danger_foreground = c.danger_foreground;
369            c.button_danger_hover = c.danger_hover;
370            c.button_danger_active = c.danger_active;
371            c.success = self.success;
372            c.success_foreground = Hsla::from(rgb(0x003b11));
373            c.success_hover = self.success.opacity(0.85);
374            c.success_active = self.success.opacity(0.7);
375            c.button_success = c.success;
376            c.button_success_foreground = c.success_foreground;
377            c.button_success_hover = c.success_hover;
378            c.button_success_active = c.success_active;
379            c.warning = self.warning;
380            c.warning_foreground = if self.is_dark() {
381                self.background
382            } else {
383                self.on_accent
384            };
385            c.warning_hover = self.warning.opacity(0.85);
386            c.warning_active = self.warning.opacity(0.7);
387            c.button_warning = c.warning;
388            c.button_warning_foreground = c.warning_foreground;
389            c.button_warning_hover = c.warning_hover;
390            c.button_warning_active = c.warning_active;
391            c.info = self.accent;
392            c.info_foreground = self.on_accent;
393            c.info_hover = c.primary_hover;
394            c.info_active = c.primary_active;
395            c.button_info = c.info;
396            c.button_info_foreground = c.info_foreground;
397            c.button_info_hover = c.info_hover;
398            c.button_info_active = c.info_active;
399            c.muted = self.inset;
400            c.muted_foreground = self.secondary;
401            c.popover = self.glass_fill();
402            c.popover_foreground = self.foreground;
403            c.group_box = self.inset;
404            c.group_box_foreground = self.foreground;
405            c.accordion = self.surface;
406            c.description_list_label = self.background;
407            c.description_list_label_foreground = self.secondary;
408            c.caret = self.accent;
409            c.selection = self.selection;
410            c.ring = self.accent.opacity(0.4);
411            c.link = self.accent;
412            c.link_hover = self.accent;
413            c.link_active = self.accent.opacity(0.75);
414            c.drag_border = self.accent;
415            c.drop_target = self.selected_fill();
416            c.sidebar = self.glass_fill();
417            c.sidebar_foreground = self.foreground;
418            c.sidebar_accent = self.selected_fill();
419            c.sidebar_accent_foreground = self.foreground;
420            c.sidebar_primary = self.accent;
421            c.sidebar_primary_foreground = self.on_accent;
422            c.sidebar_border = self.divider();
423            c.list = self.surface;
424            c.list_active = self.selected_fill();
425            c.list_active_border = self.accent;
426            c.list_hover = self.hover_fill();
427            c.list_even = self.normal_fill();
428            c.list_head = self.background;
429            c.table = self.surface;
430            c.table_active = self.selected_fill();
431            c.table_active_border = self.accent;
432            c.table_even = self.normal_fill();
433            c.table_head = self.background;
434            c.table_head_foreground = self.secondary;
435            c.table_foot = self.background;
436            c.table_foot_foreground = self.secondary;
437            c.table_hover = self.hover_fill();
438            c.table_row_border = self.divider();
439            c.tab = self.background;
440            c.tab_foreground = self.secondary;
441            c.tab_active = self.surface;
442            c.tab_active_foreground = self.foreground;
443            c.tab_bar = self.background;
444            c.tab_bar_segmented = self.inset;
445            c.title_bar = self.glass_fill();
446            c.title_bar_border = self.divider();
447            c.status_bar = self.background;
448            c.status_bar_border = self.divider();
449            c.skeleton = self.inset;
450            c.progress_bar = self.accent;
451            c.slider_bar = self.accent;
452            c.slider_thumb = Hsla::from(rgb(0xffffff));
453            c.switch = self.control_fill();
454            c.switch_thumb = Hsla::from(rgb(0xffffff));
455            c.scrollbar = self.foreground.opacity(0.);
456            c.scrollbar_thumb = self.foreground.opacity(0.28);
457            c.scrollbar_thumb_hover = self.foreground.opacity(0.48);
458            c.overlay = Hsla::from(rgb(0)).opacity(0.24);
459            c.window_border = self.border;
460            c.chart_1 = self.chart[0];
461            c.chart_2 = self.chart[1];
462            c.chart_3 = self.chart[2];
463            c.chart_4 = self.chart[3];
464            c.chart_5 = self.chart[4];
465            c.chart_bullish = self.success;
466            c.chart_bearish = self.danger;
467            c.chart_grid = self.divider();
468            c.red = self.danger;
469            c.red_light = self.danger.opacity(0.14);
470            c.green = self.success;
471            c.green_light = self.success.opacity(0.14);
472            c.blue = self.accent;
473            c.blue_light = self.selected_fill();
474            c.yellow = self.warning;
475            c.yellow_light = self.warning.opacity(0.14);
476            c.magenta = self.chart[1];
477            c.magenta_light = self.chart[1].opacity(0.14);
478            c.cyan = self.chart[2];
479            c.cyan_light = self.chart[2].opacity(0.14);
480        });
481    }
482}
483pub(crate) fn default_mono_font() -> SharedString {
484    if cfg!(target_os = "macos") {
485        "Menlo".into()
486    } else if cfg!(target_os = "windows") {
487        "Consolas".into()
488    } else {
489        "monospace".into()
490    }
491}
492pub trait ActiveTheme {
493    fn macos(&self) -> &Theme;
494}
495impl ActiveTheme for App {
496    fn macos(&self) -> &Theme {
497        self.global::<Theme>()
498    }
499}
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use gpui_kit::TestAppContext;
504    #[test]
505    fn material_tint_is_bounded_and_accessibility_is_opaque() {
506        let clear = Theme::light().with_glass_tint(-1.);
507        let tinted = Theme::light().with_glass_tint(2.);
508        assert!(clear.glass_fill().a < tinted.glass_fill().a);
509        assert_eq!(tinted.with_reduce_transparency(true).glass_fill().a, 1.);
510        assert_eq!(Theme::dark().with_glass_tint(f32::NAN).glass_tint, 0.65);
511    }
512    #[gpui_kit::test]
513    fn all_three_theme_layers_change_together(cx: &mut TestAppContext) {
514        cx.update(crate::init);
515        for theme in [Theme::dark(), Theme::light()] {
516            cx.update(|cx| {
517                theme.clone().apply(cx);
518                let base = gpui_kit::base::Theme::global(cx);
519                let kit = gpui_kit::component::theme::Theme::global(cx);
520                assert_eq!(base.tokens.colors.primary, theme.accent);
521                assert_eq!(kit.primary, theme.accent);
522                assert_eq!(kit.tokens.primary.color, theme.accent);
523                assert_eq!(base.tokens.radius.md, px(6.));
524                assert_eq!(cx.macos(), &theme);
525            });
526        }
527    }
528}