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