Skip to main content

gpui_component/
shimmer.rs

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/// Lightness difference below which a highlight would be indistinguishable from the text.
14const MIN_HIGHLIGHT_LIGHTNESS_GAP: f32 = 0.1;
15
16/// The shimmer highlight half-width.
17///
18/// A relative spread follows the text width, keeping short and long labels
19/// proportionally lit. An absolute spread keeps the band the same physical
20/// width across labels, the way a fixed gradient would.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum ShimmerSpread {
23    /// Half-width as a fraction of the text width.
24    Relative(f32),
25    /// Half-width as a fixed length.
26    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/// The appearance and timing of a reusable text shimmer.
48///
49/// By default, the highlight's half-width spans 30% of the text width and
50/// completes one left-to-right sweep every two seconds. Its color follows the
51/// current text color and active theme.
52#[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    /// Create a theme-aware shimmer with the default timing and spread.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Set the duration of one complete sweep.
68    ///
69    /// A zero duration is clamped to one millisecond.
70    pub fn duration(mut self, duration: Duration) -> Self {
71        self.duration = duration.max(Duration::from_millis(1));
72        self
73    }
74
75    /// Replace the theme-aware highlight with an explicit color.
76    pub fn highlight_color(mut self, color: impl Into<Hsla>) -> Self {
77        self.highlight_color = Some(color.into());
78        self
79    }
80
81    /// Set the highlight half-width.
82    ///
83    /// An `f32` is a fraction of the text width; finite values are clamped to
84    /// the inclusive `0.05..=1.0` range and the default is `0.3`. A [`Pixels`]
85    /// value is an absolute half-width with a one-pixel minimum. Non-finite
86    /// values leave the existing spread unchanged.
87    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    /// Set whether the highlight should move from right to left.
101    pub fn reverse(mut self, reverse: bool) -> Self {
102        self.reverse = reverse;
103        self
104    }
105
106    /// Set whether the highlight should complete one sweep instead of looping.
107    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/// Text with a smooth, theme-aware loading highlight.
130///
131/// Font, color, weight, wrapping, and truncation are inherited from the parent
132/// unless overridden through [`Styled`]. When the system requests reduced
133/// motion, the text stays visible without requesting animation frames.
134///
135/// ```ignore
136/// ShimmerText::new("Thinking…")
137///     .duration(Duration::from_secs(3))
138///     .spread(0.4)
139/// ```
140#[derive(IntoElement)]
141pub struct ShimmerText {
142    text: SharedString,
143    style: StyleRefinement,
144    shimmer_style: ShimmerStyle,
145    id: Option<ElementId>,
146}
147
148impl ShimmerText {
149    /// Create animated text with the default theme-aware shimmer.
150    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    /// Set an explicit animation identity when sibling labels are identical.
160    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
161        self.id = Some(id.into());
162        self
163    }
164
165    /// Apply a reusable shimmer appearance and timing configuration.
166    pub fn with_shimmer_style(mut self, style: ShimmerStyle) -> Self {
167        self.shimmer_style = style;
168        self
169    }
170
171    /// Set the duration of one complete sweep.
172    pub fn duration(mut self, duration: Duration) -> Self {
173        self.shimmer_style = self.shimmer_style.duration(duration);
174        self
175    }
176
177    /// Replace the theme-aware highlight with an explicit color.
178    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    /// Set the relative or absolute highlight half-width; the default is `0.3`.
184    pub fn spread(mut self, spread: impl Into<ShimmerSpread>) -> Self {
185        self.shimmer_style = self.shimmer_style.spread(spread);
186        self
187    }
188
189    /// Set whether the highlight should move from right to left.
190    pub fn reverse(mut self, reverse: bool) -> Self {
191        self.shimmer_style = self.shimmer_style.reverse(reverse);
192        self
193    }
194
195    /// Set whether the highlight should complete one sweep instead of looping.
196    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
243/// Paint an animated highlight over glyphs already laid out by `StyledText`.
244///
245/// Keeping `StyledText` as the layout owner preserves wrapping, truncation,
246/// inherited typography, and GPUI's glyph cache. Nested content masks produce
247/// a soft continuous band without rebuilding text runs on every frame.
248struct 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        // Text already in the target's lightness (e.g. `foreground` text in a dark
449        // theme) would get a band in its own color; sweep toward the other end.
450        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        // An absolute spread keeps the band width constant across text widths.
612        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        // Text that already has the target's lightness sweeps toward the other end
635        // instead of getting a band in its own color.
636        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}