1use 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 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
39pub fn set_scheme_override(scheme: Option<Scheme>) {
48 SCHEME_OVERRIDE.set(scheme);
49}
50
51pub 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
61pub fn color(role: ColorRole) -> Rgba {
63 rgba(PAINT.with(|paint| paint.borrow().current.get(role)))
64}
65
66fn 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
76pub fn blend(from: Rgba, to: Rgba, t: f32) -> Rgba {
79 rgba(kit_color(from).blend(kit_color(to), t))
80}
81
82pub fn scheme() -> Scheme {
84 PAINT.with(|paint| paint.borrow().current)
85}
86
87pub fn is_dark() -> bool {
90 PAINT.with(|paint| paint.borrow().current.is_dark())
91}
92
93pub fn system_dark(cx: &App) -> bool {
95 matches!(
96 cx.window_appearance(),
97 WindowAppearance::Dark | WindowAppearance::VibrantDark
98 )
99}
100
101#[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#[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
132pub 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
141fn 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#[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
192fn 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
204struct 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 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
234fn 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
241macro_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 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 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
319pub 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
346pub 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 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 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 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}