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