1use gpui::{
2 Animation, AnimationExt as _, App, Bounds, ContentMask, Element, ElementId, GlobalElementId,
3 Hsla, InspectorElementId, IntoElement, LayoutId, LineLayout, ParentElement as _, Pixels, Point,
4 RenderOnce, SharedString, StyleRefinement, Styled, StyledText, TextAlign, Window, WrapBoundary,
5 div, point, px, size,
6};
7use instant::Duration;
8
9use crate::{ActiveTheme as _, Colorize as _, StyledExt as _};
10
11const SHIMMER_LAYER_COUNT: usize = 12;
12const DEFAULT_SHIMMER_SPREAD: f32 = 0.3;
13const MIN_HIGHLIGHT_LIGHTNESS_GAP: f32 = 0.1;
15
16#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum ShimmerSpread {
23 Relative(f32),
25 Absolute(Pixels),
27}
28
29impl Default for ShimmerSpread {
30 fn default() -> Self {
31 Self::Relative(DEFAULT_SHIMMER_SPREAD)
32 }
33}
34
35impl From<f32> for ShimmerSpread {
36 fn from(fraction: f32) -> Self {
37 Self::Relative(fraction)
38 }
39}
40
41impl From<Pixels> for ShimmerSpread {
42 fn from(length: Pixels) -> Self {
43 Self::Absolute(length)
44 }
45}
46
47#[derive(Clone, Copy, Debug)]
53pub struct ShimmerStyle {
54 duration: Duration,
55 highlight_color: Option<Hsla>,
56 spread: ShimmerSpread,
57 reverse: bool,
58 once: bool,
59}
60
61impl ShimmerStyle {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn duration(mut self, duration: Duration) -> Self {
71 self.duration = duration.max(Duration::from_millis(1));
72 self
73 }
74
75 pub fn highlight_color(mut self, color: impl Into<Hsla>) -> Self {
77 self.highlight_color = Some(color.into());
78 self
79 }
80
81 pub fn spread(mut self, spread: impl Into<ShimmerSpread>) -> Self {
88 match spread.into() {
89 ShimmerSpread::Relative(fraction) if fraction.is_finite() => {
90 self.spread = ShimmerSpread::Relative(fraction.clamp(0.05, 1.));
91 }
92 ShimmerSpread::Absolute(length) if length.as_f32().is_finite() => {
93 self.spread = ShimmerSpread::Absolute(length.max(px(1.)));
94 }
95 _ => {}
96 }
97 self
98 }
99
100 pub fn reverse(mut self, reverse: bool) -> Self {
102 self.reverse = reverse;
103 self
104 }
105
106 pub fn once(mut self, once: bool) -> Self {
108 self.once = once;
109 self
110 }
111
112 pub(crate) fn animation(self) -> Animation {
113 loading_animation(self.duration, self.once)
114 }
115}
116
117impl Default for ShimmerStyle {
118 fn default() -> Self {
119 Self {
120 duration: Duration::from_secs(2),
121 highlight_color: None,
122 spread: ShimmerSpread::default(),
123 reverse: false,
124 once: false,
125 }
126 }
127}
128
129#[derive(IntoElement)]
141pub struct ShimmerText {
142 text: SharedString,
143 style: StyleRefinement,
144 shimmer_style: ShimmerStyle,
145 id: Option<ElementId>,
146}
147
148impl ShimmerText {
149 pub fn new(text: impl Into<SharedString>) -> Self {
151 Self {
152 text: text.into(),
153 style: StyleRefinement::default(),
154 shimmer_style: ShimmerStyle::default(),
155 id: None,
156 }
157 }
158
159 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
161 self.id = Some(id.into());
162 self
163 }
164
165 pub fn with_shimmer_style(mut self, style: ShimmerStyle) -> Self {
167 self.shimmer_style = style;
168 self
169 }
170
171 pub fn duration(mut self, duration: Duration) -> Self {
173 self.shimmer_style = self.shimmer_style.duration(duration);
174 self
175 }
176
177 pub fn highlight_color(mut self, color: impl Into<Hsla>) -> Self {
179 self.shimmer_style = self.shimmer_style.highlight_color(color);
180 self
181 }
182
183 pub fn spread(mut self, spread: impl Into<ShimmerSpread>) -> Self {
185 self.shimmer_style = self.shimmer_style.spread(spread);
186 self
187 }
188
189 pub fn reverse(mut self, reverse: bool) -> Self {
191 self.shimmer_style = self.shimmer_style.reverse(reverse);
192 self
193 }
194
195 pub fn once(mut self, once: bool) -> Self {
197 self.shimmer_style = self.shimmer_style.once(once);
198 self
199 }
200}
201
202impl Styled for ShimmerText {
203 fn style(&mut self) -> &mut StyleRefinement {
204 &mut self.style
205 }
206}
207
208impl RenderOnce for ShimmerText {
209 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
210 let id = self.id.unwrap_or_else(|| self.text.clone().into());
211 let container = div().min_w_0().refine_style(&self.style);
212
213 if cx.reduce_motion() {
214 return container
215 .child(StyledText::new(self.text))
216 .into_any_element();
217 }
218
219 let tokens = cx.theme().semantic_tokens();
220 let reverse = self.shimmer_style.reverse;
221 let shimmer = ShimmerGlyphs {
222 text: StyledText::new(self.text),
223 highlight_color: self.shimmer_style.highlight_color,
224 background: tokens.colors.background,
225 foreground: tokens.colors.foreground,
226 dark: cx.theme().is_dark(),
227 spread: self.shimmer_style.spread,
228 phase: 0.,
229 }
230 .with_animation(
231 id,
232 self.shimmer_style.animation(),
233 move |mut this, phase| {
234 this.phase = if reverse { 1. - phase } else { phase };
235 this
236 },
237 );
238
239 container.child(shimmer).into_any_element()
240 }
241}
242
243struct ShimmerGlyphs {
249 text: StyledText,
250 highlight_color: Option<Hsla>,
251 background: Hsla,
252 foreground: Hsla,
253 dark: bool,
254 spread: ShimmerSpread,
255 phase: f32,
256}
257
258impl ShimmerGlyphs {
259 fn paint_highlight(&self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
260 let masks = std::array::from_fn::<_, SHIMMER_LAYER_COUNT, _>(|layer| {
261 shimmer_band_bounds(bounds, self.phase, self.spread, layer)
262 .map(|bounds| ContentMask { bounds })
263 });
264
265 if masks.iter().all(Option::is_none) {
266 return;
267 }
268
269 let color = shimmer_highlight_color(
270 window.text_style().color,
271 self.background,
272 self.foreground,
273 self.dark,
274 self.highlight_color,
275 );
276 let layout = self.text.layout();
277 let line_height = layout.line_height();
278 let text_align = window.text_style().text_align;
279 let mut line_origin = bounds.origin;
280
281 window.paint_layer(bounds, |window| {
282 for wrapped_line in layout.line_layouts() {
283 let line = &wrapped_line.unwrapped_layout;
284 let baseline_offset = point(
285 px(0.),
286 (line_height - line.ascent - line.descent) / 2. + line.ascent,
287 );
288 let mut wraps = wrapped_line.wrap_boundaries.iter().peekable();
289 let mut glyph_origin = point(
290 shimmer_aligned_origin_x(
291 line_origin,
292 bounds.size.width,
293 px(0.),
294 text_align,
295 line,
296 wraps.peek().copied(),
297 ),
298 line_origin.y,
299 );
300 let mut previous_glyph_position = Point::default();
301
302 for (run_index, run) in line.runs.iter().enumerate() {
303 let glyph_size = cx
304 .text_system()
305 .bounding_box(run.font_id, line.font_size)
306 .size;
307
308 for (glyph_index, glyph) in run.glyphs.iter().enumerate() {
309 glyph_origin.x += glyph.position.x - previous_glyph_position.x;
310
311 if wraps.peek().is_some_and(|wrap| {
312 wrap.run_ix == run_index && wrap.glyph_ix == glyph_index
313 }) {
314 wraps.next();
315 glyph_origin.x = shimmer_aligned_origin_x(
316 line_origin,
317 bounds.size.width,
318 glyph.position.x,
319 text_align,
320 line,
321 wraps.peek().copied(),
322 );
323 glyph_origin.y += line_height;
324 }
325
326 previous_glyph_position = glyph.position;
327
328 if glyph.is_emoji {
329 continue;
330 }
331
332 let glyph_bounds = Bounds::new(glyph_origin, glyph_size);
333 let paint_origin =
334 glyph_origin + baseline_offset + point(px(0.), glyph.position.y);
335
336 for mask in masks.iter().flatten() {
337 if !glyph_bounds.intersects(&mask.bounds) {
338 continue;
339 }
340
341 window.with_content_mask(Some(*mask), |window| {
342 let _ = window.paint_glyph(
343 paint_origin,
344 run.font_id,
345 glyph.id,
346 line.font_size,
347 color,
348 );
349 });
350 }
351 }
352 }
353
354 line_origin.y += wrapped_line.size(line_height).height;
355 }
356 });
357 }
358}
359
360impl IntoElement for ShimmerGlyphs {
361 type Element = Self;
362
363 fn into_element(self) -> Self::Element {
364 self
365 }
366}
367
368impl Element for ShimmerGlyphs {
369 type RequestLayoutState = ();
370 type PrepaintState = ();
371
372 fn id(&self) -> Option<ElementId> {
373 None
374 }
375
376 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
377 None
378 }
379
380 fn request_layout(
381 &mut self,
382 global_id: Option<&GlobalElementId>,
383 inspector_id: Option<&InspectorElementId>,
384 window: &mut Window,
385 cx: &mut App,
386 ) -> (LayoutId, Self::RequestLayoutState) {
387 self.text
388 .request_layout(global_id, inspector_id, window, cx)
389 }
390
391 fn prepaint(
392 &mut self,
393 global_id: Option<&GlobalElementId>,
394 inspector_id: Option<&InspectorElementId>,
395 bounds: Bounds<Pixels>,
396 layout: &mut Self::RequestLayoutState,
397 window: &mut Window,
398 cx: &mut App,
399 ) -> Self::PrepaintState {
400 self.text
401 .prepaint(global_id, inspector_id, bounds, layout, window, cx);
402 }
403
404 fn paint(
405 &mut self,
406 global_id: Option<&GlobalElementId>,
407 inspector_id: Option<&InspectorElementId>,
408 bounds: Bounds<Pixels>,
409 layout: &mut Self::RequestLayoutState,
410 prepaint: &mut Self::PrepaintState,
411 window: &mut Window,
412 cx: &mut App,
413 ) {
414 self.text.paint(
415 global_id,
416 inspector_id,
417 bounds,
418 layout,
419 prepaint,
420 window,
421 cx,
422 );
423 self.paint_highlight(bounds, window, cx);
424 }
425}
426
427pub(crate) fn loading_animation(duration: Duration, once: bool) -> Animation {
428 if once {
429 Animation::new(duration)
430 } else {
431 Animation::new(duration).repeat_synced()
432 }
433}
434
435fn shimmer_highlight_color(
436 text: Hsla,
437 background: Hsla,
438 foreground: Hsla,
439 dark: bool,
440 override_color: Option<Hsla>,
441) -> Hsla {
442 let highlight = override_color.unwrap_or_else(|| {
443 let (target, opposite) = if dark {
444 (foreground, background)
445 } else {
446 (background, foreground)
447 };
448 let target = if (target.l - text.l).abs() < MIN_HIGHLIGHT_LIGHTNESS_GAP {
451 opposite
452 } else {
453 target
454 };
455 text.mix_oklab(target, 0.2)
456 });
457 let peak_opacity: f32 = if dark { 0.6 } else { 0.75 };
458 let layer_opacity = 1. - (1. - peak_opacity).powf(1. / SHIMMER_LAYER_COUNT as f32);
459
460 highlight.opacity(layer_opacity)
461}
462
463fn shimmer_band_bounds(
464 bounds: Bounds<Pixels>,
465 phase: f32,
466 spread: ShimmerSpread,
467 layer: usize,
468) -> Option<Bounds<Pixels>> {
469 let width = bounds.size.width.as_f32();
470
471 if width <= 0. || bounds.size.height <= px(0.) || layer >= SHIMMER_LAYER_COUNT {
472 return None;
473 }
474
475 let half_width = match spread {
476 ShimmerSpread::Relative(fraction) => width * fraction,
477 ShimmerSpread::Absolute(length) => length.as_f32(),
478 };
479 let padding = half_width / width + 0.05;
480 let center = phase.mul_add(1. + padding * 2., -padding) * width;
481 let radius = half_width * (1. - layer as f32 / SHIMMER_LAYER_COUNT as f32);
482 let left = (center - radius).max(0.);
483 let right = (center + radius).min(width);
484
485 (right > left).then(|| {
486 Bounds::new(
487 point(bounds.origin.x + px(left), bounds.origin.y),
488 size(px(right - left), bounds.size.height),
489 )
490 })
491}
492
493fn shimmer_aligned_origin_x(
494 origin: Point<Pixels>,
495 align_width: Pixels,
496 previous_glyph_x: Pixels,
497 align: TextAlign,
498 layout: &LineLayout,
499 next_wrap: Option<&WrapBoundary>,
500) -> Pixels {
501 let line_end = next_wrap
502 .map(|wrap| layout.runs[wrap.run_ix].glyphs[wrap.glyph_ix].position.x)
503 .unwrap_or(layout.width);
504 let line_width = line_end - previous_glyph_x;
505
506 match align {
507 TextAlign::Left => origin.x,
508 TextAlign::Center => (origin.x * 2. + align_width - line_width) / 2.,
509 TextAlign::Right => origin.x + align_width - line_width,
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
518 fn test_shimmer_builder() {
519 let color = Hsla::white();
520 let style = ShimmerStyle::new()
521 .duration(Duration::from_secs(3))
522 .highlight_color(color)
523 .spread(0.45)
524 .reverse(true)
525 .once(true);
526
527 assert_eq!(style.duration, Duration::from_secs(3));
528 assert_eq!(style.highlight_color, Some(color));
529 assert_eq!(style.spread, ShimmerSpread::Relative(0.45));
530 assert!(style.reverse);
531 assert!(style.once);
532
533 let text = ShimmerText::new("Thinking")
534 .id("thinking")
535 .with_shimmer_style(style)
536 .duration(Duration::from_secs(4))
537 .spread(0.5)
538 .reverse(false)
539 .once(false)
540 .opacity(0.8);
541
542 assert_eq!(text.text.as_ref(), "Thinking");
543 assert_eq!(text.shimmer_style.duration, Duration::from_secs(4));
544 assert_eq!(text.shimmer_style.spread, ShimmerSpread::Relative(0.5));
545 assert!(!text.shimmer_style.reverse);
546 assert!(!text.shimmer_style.once);
547 assert_eq!(text.style.opacity, Some(0.8));
548 assert_eq!(text.id, Some("thinking".into()));
549
550 assert_eq!(
551 ShimmerStyle::new().spread(0.).spread,
552 ShimmerSpread::Relative(0.05)
553 );
554 assert_eq!(
555 ShimmerStyle::new().spread(2.).spread,
556 ShimmerSpread::Relative(1.)
557 );
558 assert_eq!(
559 ShimmerStyle::new().spread(f32::NAN).spread,
560 ShimmerSpread::default()
561 );
562 assert_eq!(
563 ShimmerStyle::new().spread(px(0.)).spread,
564 ShimmerSpread::Absolute(px(1.))
565 );
566 assert_eq!(
567 ShimmerStyle::new().spread(px(48.)).spread,
568 ShimmerSpread::Absolute(px(48.))
569 );
570 assert_eq!(
571 ShimmerStyle::new().spread(px(f32::NAN)).spread,
572 ShimmerSpread::default()
573 );
574 assert_eq!(
575 ShimmerStyle::new().duration(Duration::ZERO).duration,
576 Duration::from_millis(1)
577 );
578 }
579
580 #[test]
581 fn test_shimmer_band_moves_smoothly_across_text() {
582 let bounds = Bounds::new(point(px(10.), px(20.)), size(px(100.), px(18.)));
583 let spread = ShimmerSpread::default();
584
585 assert!(shimmer_band_bounds(bounds, 0., spread, 0).is_none());
586 assert!(shimmer_band_bounds(bounds, 1., spread, 0).is_none());
587
588 let early = shimmer_band_bounds(bounds, 0.35, spread, 0).unwrap();
589 let late = shimmer_band_bounds(bounds, 0.65, spread, 0).unwrap();
590 assert!(early.origin.x < late.origin.x);
591
592 let outer = shimmer_band_bounds(bounds, 0.5, spread, 0).unwrap();
593 let inner = shimmer_band_bounds(bounds, 0.5, spread, SHIMMER_LAYER_COUNT - 1).unwrap();
594 assert!(inner.origin.x > outer.origin.x);
595 assert!(inner.size.width < outer.size.width);
596 assert!(shimmer_band_bounds(bounds, 0.5, spread, SHIMMER_LAYER_COUNT).is_none());
597 assert!(
598 shimmer_band_bounds(
599 Bounds::new(bounds.origin, size(px(0.), px(18.))),
600 0.5,
601 spread,
602 0
603 )
604 .is_none()
605 );
606
607 let narrow = shimmer_band_bounds(bounds, 0.5, ShimmerSpread::Relative(0.1), 0).unwrap();
608 let wide = shimmer_band_bounds(bounds, 0.5, ShimmerSpread::Relative(0.5), 0).unwrap();
609 assert!(narrow.size.width < wide.size.width);
610
611 let absolute = ShimmerSpread::Absolute(px(20.));
613 let band = shimmer_band_bounds(bounds, 0.5, absolute, 0).unwrap();
614 assert_eq!(band.size.width, px(40.));
615 let wider_bounds = Bounds::new(bounds.origin, size(px(200.), px(18.)));
616 let wider_band = shimmer_band_bounds(wider_bounds, 0.5, absolute, 0).unwrap();
617 assert_eq!(wider_band.size.width, px(40.));
618 }
619
620 #[test]
621 fn test_shimmer_highlight_stays_bright_in_both_themes() {
622 let black = Hsla::black();
623 let white = Hsla::white();
624 let muted = white.mix_oklab(black, 0.55);
625 let light = shimmer_highlight_color(black, white, black, false, None);
626 let dark = shimmer_highlight_color(muted, black, white, true, None);
627
628 assert!(light.l > black.l);
629 assert!(dark.l > muted.l);
630 assert!(light.a > dark.a);
631 assert!((1. - (1. - light.a).powi(SHIMMER_LAYER_COUNT as i32) - 0.75).abs() < 0.001);
632 assert!((1. - (1. - dark.a).powi(SHIMMER_LAYER_COUNT as i32) - 0.6).abs() < 0.001);
633
634 let on_dark_foreground = shimmer_highlight_color(white, black, white, true, None);
637 assert!(white.l - on_dark_foreground.l > 0.3);
638 let on_light_background = shimmer_highlight_color(white, white, black, false, None);
639 assert!(white.l - on_light_background.l > 0.3);
640
641 let custom = shimmer_highlight_color(black, white, black, false, Some(muted));
642 assert_eq!(custom.h, muted.h);
643 assert_eq!(custom.s, muted.s);
644 assert_eq!(custom.l, muted.l);
645
646 let animation = loading_animation(Duration::from_secs(3), false);
647 assert_eq!(animation.duration, Duration::from_secs(3));
648 assert!(animation.synced);
649 assert!(!animation.oneshot);
650 assert_eq!(animation.max_fps, None);
651
652 let animation = loading_animation(Duration::from_secs(3), true);
653 assert_eq!(animation.duration, Duration::from_secs(3));
654 assert!(animation.oneshot);
655 assert!(!animation.synced);
656 }
657}