Skip to main content

gpui_kit_material/
theme.rs

1//! The painted scheme: what [`color`] returns on the current frame.
2//!
3//! A palette change does not snap: the painted scheme blends from wherever
4//! it is toward the new target on the slow effects spring, and a further
5//! change retargets from the current paint. Reduced motion paints the
6//! target at once.
7use crate::color::{Color, ColorRole, PaletteId, Scheme, ThemePreferences};
8use crate::motion::{self, MotionToken};
9use gpui::{App, Global, Hsla, Pixels, Rgba, Window, WindowAppearance};
10use gpui_kit::component::{Theme as ComponentTheme, ThemeMode as ComponentThemeMode};
11use std::cell::{Cell, RefCell};
12#[cfg(not(target_family = "wasm"))]
13use std::time::Instant;
14#[cfg(target_family = "wasm")]
15use web_time::Instant;
16
17struct Paint {
18    target: Option<Scheme>,
19    from: Scheme,
20    current: Scheme,
21    started: Instant,
22    motion: motion::MotionCurve,
23}
24
25thread_local! {
26    // GPUI renders application chrome and overlays on its UI thread, so a
27    // thread-local paint keeps role lookups context-free without globals
28    // that need `&App`.
29    static PAINT: RefCell<Paint> = RefCell::new(Paint {
30        target: None,
31        from: Scheme::from_palette(PaletteId::Paper),
32        current: Scheme::from_palette(PaletteId::Paper),
33        started: Instant::now(),
34        motion: motion::MotionScheme::Expressive.resolve(MotionToken::SlowEffects),
35    });
36    static SCHEME_OVERRIDE: Cell<Option<Scheme>> = const { Cell::new(None) };
37}
38
39/// Paint `scheme` in place of whatever target the application root passes to
40/// [`advance_component_theme`] or [`apply_scheme`], until cleared with `None`.
41///
42/// This is for a scheme chooser below the view that owns the theme, such as a
43/// seed-color control on a settings page. It takes effect on the next advance;
44/// the caller must also advance or refresh, because the root has no reason to.
45/// An application whose root owns the choice should pass the scheme as its
46/// target instead.
47pub fn set_scheme_override(scheme: Option<Scheme>) {
48    SCHEME_OVERRIDE.set(scheme);
49}
50
51/// Convert a kit color to GPUI's.
52pub fn rgba(color: Color) -> Rgba {
53    Rgba {
54        r: color.r,
55        g: color.g,
56        b: color.b,
57        a: color.a,
58    }
59}
60
61/// The current paint of a role.
62pub fn color(role: ColorRole) -> Rgba {
63    rgba(PAINT.with(|paint| paint.borrow().current.get(role)))
64}
65
66/// Convert a GPUI color to the kit's.
67fn kit_color(color: Rgba) -> Color {
68    Color {
69        r: color.r,
70        g: color.g,
71        b: color.b,
72        a: color.a,
73    }
74}
75
76/// Linear interpolation between two GPUI colors: [`Color::blend`], the one
77/// implementation, applied to GPUI's color type.
78pub fn blend(from: Rgba, to: Rgba, t: f32) -> Rgba {
79    rgba(kit_color(from).blend(kit_color(to), t))
80}
81
82/// The current paint as a whole, e.g. to mirror it into another toolkit's theme.
83pub fn scheme() -> Scheme {
84    PAINT.with(|paint| paint.borrow().current)
85}
86
87/// Whether the painted scheme is a dark one. Mid-transition this follows
88/// whichever palette the paint is closer to.
89pub fn is_dark() -> bool {
90    PAINT.with(|paint| paint.borrow().current.is_dark())
91}
92
93/// Whether the system currently asks for a dark appearance.
94pub fn system_dark(cx: &App) -> bool {
95    matches!(
96        cx.window_appearance(),
97        WindowAppearance::Dark | WindowAppearance::VibrantDark
98    )
99}
100
101/// Paint `preference` resolved against the system appearance. Call at the
102/// start of each root render; it schedules frames while a transition runs.
103/// Returns whether the paint changed this frame, so an application can
104/// mirror the scheme into other toolkits only when needed.
105///
106/// This render-time path does not update the Component theme.
107#[deprecated(
108    note = "render-time path that leaves the Component theme stale; use `init_component_theme` \
109            and `advance_component_theme` with `preference.resolve(system_dark(cx))`, or \
110            `apply_scheme` for Base-only rendering"
111)]
112pub fn apply(preference: &ThemePreferences, window: &mut Window, cx: &mut App) -> bool {
113    apply_scheme(
114        Scheme::from_palette(preference.resolve(system_dark(cx))),
115        window,
116        cx,
117    )
118}
119
120/// Paint one palette, transitioning from the current paint.
121///
122/// This render-time path does not update the Component theme.
123#[deprecated(
124    note = "render-time path that leaves the Component theme stale; use `init_component_theme` \
125            and `advance_component_theme` with `Scheme::from_palette(id)`, or \
126            `apply_scheme(Scheme::from_palette(id), ..)` for Base-only rendering"
127)]
128pub fn apply_palette(id: PaletteId, window: &mut Window, cx: &mut App) -> bool {
129    apply_scheme(Scheme::from_palette(id), window, cx)
130}
131
132/// Apply a complete custom semantic scheme with the same animated transitions.
133pub fn apply_scheme(target: Scheme, window: &mut Window, cx: &mut App) -> bool {
134    let (changed, running) = advance_paint(target, cx);
135    if running {
136        window.request_animation_frame();
137    }
138    changed
139}
140
141// Both the Base-only renderer and the Component pre-frame driver use one sampler.
142fn advance_paint(target: Scheme, cx: &App) -> (bool, bool) {
143    let target = SCHEME_OVERRIDE.get().unwrap_or(target);
144    let reduce_motion = cx.reduce_motion();
145    let effects = motion::curve(MotionToken::SlowEffects);
146    PAINT.with(|paint| {
147        let mut paint = paint.borrow_mut();
148        let now = cx.background_executor().now();
149        if paint.target.is_none() {
150            paint.target = Some(target);
151            paint.current = target;
152            paint.from = target;
153            paint.started = now;
154            paint.motion = effects;
155            return (true, false);
156        }
157        if paint.target != Some(target) {
158            paint.from = paint.current;
159            paint.target = Some(target);
160            paint.started = now;
161            paint.motion = effects;
162        }
163        if reduce_motion {
164            let changed = paint.current != target;
165            paint.current = target;
166            paint.from = target;
167            paint.started = now;
168            return (changed, false);
169        }
170        let t = (now.duration_since(paint.started).as_secs_f32()
171            / paint.motion.duration().as_secs_f32())
172        .min(1.);
173        let next = paint.from.blend(&target, paint.motion.ease()(t));
174        let changed = next != paint.current;
175        paint.current = next;
176        (changed, paint.from != target && t < 1.)
177    })
178}
179
180/// Set the text scale: 14px is 1rem at 100%, and every rem token follows it.
181///
182/// This leaves the Component theme's font size behind the window's rem size.
183#[deprecated(
184    note = "leaves the Component theme's font size stale; pass the scale to \
185            `init_component_theme` and `advance_component_theme`, which Base-only \
186            windows may call too"
187)]
188pub fn apply_scale(scale: f32, window: &mut Window) {
189    set_rem_scale(scale, window);
190}
191
192// The single authority for the window's rem size: both the legacy
193// `apply_scale` path and `advance_component_theme` resolve the text scale
194// here. Returns whether the rem size changed.
195fn set_rem_scale(scale: f32, window: &mut Window) -> bool {
196    let font_size = scale_pixels(scale);
197    let changed = window.rem_size() != font_size;
198    if changed {
199        window.set_rem_size(font_size);
200    }
201    changed
202}
203
204// Explicit opt-in: existing Base-only callers never require a Component global.
205struct ComponentThemeBridge;
206impl Global for ComponentThemeBridge {}
207
208fn scale_pixels(scale: f32) -> Pixels {
209    let scale = if scale.is_finite() && scale > 0. {
210        scale
211    } else {
212        1.
213    };
214    let pixels = crate::tokens::typography::BASE_PX * scale;
215    gpui::px(if pixels.is_finite() {
216        pixels
217    } else {
218        crate::tokens::typography::BASE_PX
219    })
220}
221
222fn component_font_family() -> &'static str {
223    // Follow the currently implemented Material type scale. A future reference
224    // font migration must update the scale and font assets together.
225    crate::tokens::typography::TypeRole::BodyMedium
226        .scale()
227        .font_family
228}
229
230fn component_color(painted: Scheme, role: ColorRole) -> Hsla {
231    rgba(painted.get(role)).into()
232}
233
234/// `role` at `opacity`, e.g. the text selection highlight.
235fn component_color_at(painted: Scheme, role: ColorRole, opacity: f32) -> Hsla {
236    let mut color = rgba(painted.get(role));
237    color.a *= opacity;
238    color.into()
239}
240
241// Only generic semantic colors belong to this bridge. Full M3 role storage,
242// state-layer recipes, motion, spacing and elevation remain Material-owned.
243// A role may carry an opacity (`role @ opacity`).
244macro_rules! component_role_projection {
245    ($($field:ident => $role:ident $(@ $opacity:expr)?),* $(,)?) => {
246        fn component_projection_matches(theme: &ComponentTheme, painted: Scheme, scale: f32) -> bool {
247            theme.font_size == scale_pixels(scale)
248                && theme.font_family.as_ref() == component_font_family()
249                $(&& theme.colors.$field == component_role_projection!(@color painted, $role $(, $opacity)?)
250                  && theme.tokens.$field
251                      == component_role_projection!(@color painted, $role $(, $opacity)?).into())*
252        }
253
254        fn write_component_projection(theme: &mut ComponentTheme, painted: Scheme, scale: f32) {
255            theme.font_size = scale_pixels(scale);
256            theme.font_family = component_font_family().into();
257            $(let color = component_role_projection!(@color painted, $role $(, $opacity)?);
258              theme.colors.$field = color;
259              theme.tokens.$field = color.into();)*
260        }
261    };
262    (@color $painted:ident, $role:ident) => {
263        component_color($painted, ColorRole::$role)
264    };
265    (@color $painted:ident, $role:ident, $opacity:expr) => {
266        component_color_at($painted, ColorRole::$role, $opacity)
267    };
268}
269
270component_role_projection! {
271    background => SURFACE,
272    foreground => ON_SURFACE,
273    popover => SURFACE_CONTAINER_HIGH,
274    popover_foreground => ON_SURFACE,
275    primary => PRIMARY,
276    primary_foreground => ON_PRIMARY,
277    secondary => SECONDARY_CONTAINER,
278    secondary_foreground => ON_SECONDARY_CONTAINER,
279    muted => SURFACE_CONTAINER,
280    muted_foreground => ON_SURFACE_VARIANT,
281    accent => TERTIARY_CONTAINER,
282    accent_foreground => ON_TERTIARY_CONTAINER,
283    danger => ERROR,
284    danger_foreground => ON_ERROR,
285    border => OUTLINE_VARIANT,
286    input => OUTLINE,
287    ring => PRIMARY,
288    // Kit's text inputs paint the caret and selection from these.
289    caret => PRIMARY,
290    selection => PRIMARY @ crate::tokens::state::TEXT_SELECTION,
291}
292
293fn sync_component_theme(painted: Scheme, scale: f32, cx: &mut App) -> bool {
294    if cx.try_global::<ComponentThemeBridge>().is_none()
295        || cx.try_global::<ComponentTheme>().is_none()
296    {
297        return false;
298    }
299    let mode = if painted.is_dark() {
300        ComponentThemeMode::Dark
301    } else {
302        ComponentThemeMode::Light
303    };
304    let mode_changed = ComponentTheme::global(cx).mode != mode;
305    if mode_changed {
306        // Component loads its registered mode config after an update that changes
307        // mode. Perform that step first so it cannot overwrite the M3 projection.
308        ComponentTheme::update(cx, |theme| theme.mode = mode);
309    }
310    let changed = !component_projection_matches(ComponentTheme::global(cx), painted, scale);
311    if changed {
312        ComponentTheme::update(cx, |theme| {
313            write_component_projection(theme, painted, scale)
314        });
315    }
316    mode_changed || changed
317}
318
319/// Enable the Component theme bridge after `gpui_kit::init`, before opening a
320/// window or returning its content builder. Initialize from the final startup
321/// palette and scale, including capture overrides, so the first Root preparation
322/// sees the correct colors and rem size. Returns false when Component is absent.
323/// No transition is started for this initial paint. The bridge owns the sans
324/// family from Material's current body type scale, its base size and projected
325/// colors. The projection does not own monospace fonts or unrelated geometry;
326/// Component mode configurations retain their normal behavior for those fields.
327pub fn init_component_theme(initial: Scheme, scale: f32, cx: &mut App) -> bool {
328    if cx.try_global::<ComponentTheme>().is_none() {
329        return false;
330    }
331    PAINT.with(|paint| {
332        let mut paint = paint.borrow_mut();
333        paint.target = Some(initial);
334        paint.from = initial;
335        paint.current = initial;
336        paint.started = cx.background_executor().now();
337        paint.motion = motion::curve(MotionToken::SlowEffects);
338    });
339    if cx.try_global::<ComponentThemeBridge>().is_none() {
340        cx.set_global(ComponentThemeBridge);
341    }
342    sync_component_theme(initial, scale, cx);
343    true
344}
345
346/// Advance Material paint and its enabled Component projection before Root
347/// rendering. Call from theme/scale events and next-frame callbacks, not from
348/// `Render`. Returns whether the animation needs another frame; the caller owns
349/// scheduling and should retain only one pending callback. This function never
350/// schedules an animation itself. Base-only callers remain supported.
351pub fn advance_component_theme(
352    target: Scheme,
353    scale: f32,
354    window: &mut Window,
355    cx: &mut App,
356) -> bool {
357    let (changed, running) = advance_paint(target, cx);
358    sync_component_theme(scheme(), scale, cx);
359    let scale_changed = set_rem_scale(scale, window);
360    if changed || scale_changed {
361        // Material colors are thread-local, so cached Material-only entities do
362        // not observe Component's global. Its queued refresh handles ordinary
363        // event boundaries; this also makes synchronous draws see current paint.
364        // This refresh is the single invalidation owner for a theme or scale
365        // step: callers need not also notify their view for it.
366        window.refresh();
367    }
368    running
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use gpui::{Context, IntoElement, Render, TestAppContext, div};
375    use std::time::Duration;
376
377    #[gpui::test]
378    fn an_override_wins_over_the_target_until_cleared(cx: &mut TestAppContext) {
379        let chosen = Scheme::from_palette(PaletteId::Ink);
380        let target = Scheme::from_palette(PaletteId::Paper);
381        cx.update(|cx| {
382            cx.set_reduce_motion(true);
383            set_scheme_override(Some(chosen));
384            advance_paint(target, cx);
385            assert_eq!(scheme(), chosen);
386            set_scheme_override(None);
387            advance_paint(target, cx);
388            assert_eq!(scheme(), target);
389        });
390    }
391
392    #[test]
393    fn component_projection_keeps_unowned_tokens_and_uses_one_rem_baseline() {
394        let mut theme = ComponentTheme::default();
395        theme.font_family = "Caller Font".into();
396        theme.mono_font_family = "Caller Mono".into();
397        theme.radius = gpui::px(13.);
398        let unowned = theme.colors.chart_1;
399        let painted = Scheme::from_palette(PaletteId::Paper)
400            .with_role(ColorRole::PRIMARY_FIXED, Color::hex(0x123456));
401        write_component_projection(&mut theme, painted, 1.5);
402        assert!(component_projection_matches(&theme, painted, 1.5));
403        assert_eq!(theme.font_size, gpui::px(21.));
404        assert_eq!(theme.font_family.as_ref(), component_font_family());
405        assert_eq!(theme.mono_font_family.as_ref(), "Caller Mono");
406        assert_eq!(theme.radius, gpui::px(13.));
407        assert_eq!(theme.colors.chart_1, unowned);
408        assert_eq!(painted.get(ColorRole::PRIMARY_FIXED), Color::hex(0x123456));
409        // Text editing paints the caret in primary and the selection in
410        // primary at the M3 selection opacity.
411        let primary: Hsla = rgba(painted.get(ColorRole::PRIMARY)).into();
412        assert_eq!(theme.colors.caret, primary);
413        assert_eq!(theme.tokens.caret.color, primary);
414        assert_eq!(
415            theme.colors.selection,
416            primary.opacity(crate::tokens::state::TEXT_SELECTION)
417        );
418        assert_eq!(theme.tokens.selection.color, theme.colors.selection);
419        // Both representations are managed, including a pre-existing mismatched token.
420        theme.tokens.primary = Hsla::default().into();
421        assert!(!component_projection_matches(&theme, painted, 1.5));
422        write_component_projection(&mut theme, painted, 1.5);
423        assert!(component_projection_matches(&theme, painted, 1.5));
424    }
425
426    #[test]
427    fn blend_matches_the_kit_color_blend() {
428        let from = Color::hex(0x204060);
429        let to = Color::hexa(0xffeeddcc);
430        for t in [-1., 0., 0.3, 1., 2., f32::NAN] {
431            assert_eq!(blend(rgba(from), rgba(to), t), rgba(from.blend(to, t)));
432        }
433    }
434
435    struct ScaleHarness;
436    impl Render for ScaleHarness {
437        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
438            div()
439        }
440    }
441
442    #[gpui::test]
443    #[allow(
444        deprecated,
445        reason = "checks that the deprecated `apply_scale` still shares the rem authority"
446    )]
447    fn legacy_and_component_paths_share_one_rem_authority(cx: &mut TestAppContext) {
448        let (_, cx) = cx.add_window_view(|_, _| ScaleHarness);
449        cx.update(|window, cx| {
450            apply_scale(1.5, window);
451            assert_eq!(window.rem_size(), gpui::px(21.));
452            apply_scale(f32::NAN, window);
453            assert_eq!(window.rem_size(), gpui::px(14.));
454            advance_component_theme(Scheme::from_palette(PaletteId::Paper), 1.25, window, cx);
455            assert_eq!(window.rem_size(), gpui::px(17.5));
456            assert!(
457                !set_rem_scale(1.25, window),
458                "an equal scale is not a change"
459            );
460            assert!(set_rem_scale(-2., window));
461            assert_eq!(window.rem_size(), scale_pixels(1.));
462        });
463    }
464
465    #[gpui::test]
466    fn component_bridge_is_explicit_and_idempotent_across_modes(cx: &mut TestAppContext) {
467        cx.update(|cx| {
468            let initial = Scheme::from_palette(PaletteId::Paper);
469            assert!(!init_component_theme(initial, 1., cx));
470            assert!(cx.try_global::<ComponentTheme>().is_none());
471            gpui_kit::init(cx);
472            assert!(init_component_theme(initial, 1., cx));
473            assert_eq!(scheme(), initial);
474            assert!(!sync_component_theme(initial, 1., cx));
475            for palette in [PaletteId::Ink, PaletteId::Paper] {
476                let target = Scheme::from_palette(palette);
477                assert!(sync_component_theme(target, 1.25, cx));
478                let theme = ComponentTheme::global(cx);
479                assert_eq!(theme.mode.is_dark(), target.is_dark());
480                assert!(component_projection_matches(theme, target, 1.25));
481                assert_eq!(theme.font_family.as_ref(), component_font_family());
482                assert!(!sync_component_theme(target, 1.25, cx));
483            }
484        });
485    }
486
487    struct PaletteHarness {
488        palette: PaletteId,
489        motion: motion::MotionScheme,
490    }
491    impl Render for PaletteHarness {
492        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
493            motion::set_motion_scheme(self.motion);
494            apply_scheme(Scheme::from_palette(self.palette), window, cx);
495            div()
496        }
497    }
498
499    #[gpui::test]
500    fn palette_transition_keeps_its_curve_when_motion_scheme_changes(cx: &mut TestAppContext) {
501        let (view, cx) = cx.add_window_view(|_, _| PaletteHarness {
502            palette: PaletteId::Paper,
503            motion: motion::MotionScheme::Expressive,
504        });
505        cx.update(|window, cx| {
506            cx.set_reduce_motion(true);
507            window.refresh();
508            window.draw(cx).clear(cx);
509            cx.set_reduce_motion(false);
510            view.update(cx, |view, _| view.palette = PaletteId::Ink);
511            window.refresh();
512            window.draw(cx).clear(cx);
513        });
514        cx.executor().advance_clock(Duration::from_millis(100));
515        cx.update(|window, cx| {
516            window.refresh();
517            window.draw(cx).clear(cx);
518        });
519        let before = scheme();
520        cx.update(|window, cx| {
521            view.update(cx, |view, _| view.motion = motion::MotionScheme::Standard);
522            window.refresh();
523            window.draw(cx).clear(cx);
524        });
525        assert_eq!(
526            scheme(),
527            before,
528            "changing a motion preference must not jump color"
529        );
530        cx.update(|window, cx| {
531            view.update(cx, |view, _| view.palette = PaletteId::Paper);
532            window.refresh();
533            window.draw(cx).clear(cx);
534        });
535        assert_eq!(
536            scheme(),
537            before,
538            "a reversed palette starts at current paint"
539        );
540        cx.executor().advance_clock(Duration::from_millis(50));
541        cx.update(|window, cx| {
542            window.refresh();
543            window.draw(cx).clear(cx);
544        });
545        assert_ne!(scheme(), before);
546        cx.update(|window, cx| {
547            cx.set_reduce_motion(true);
548            window.refresh();
549            window.draw(cx).clear(cx);
550        });
551        assert_eq!(scheme(), Scheme::from_palette(PaletteId::Paper));
552        cx.update(|window, cx| {
553            cx.set_reduce_motion(false);
554            window.refresh();
555            window.draw(cx).clear(cx);
556        });
557        assert_eq!(
558            scheme(),
559            Scheme::from_palette(PaletteId::Paper),
560            "restoring motion must not resume the completed fade"
561        );
562        cx.executor().advance_clock(Duration::from_millis(50));
563        cx.update(|window, cx| {
564            window.refresh();
565            window.draw(cx).clear(cx);
566        });
567        assert_eq!(scheme(), Scheme::from_palette(PaletteId::Paper));
568        motion::set_motion_scheme(motion::MotionScheme::Expressive);
569    }
570}