Skip to main content

tessera_components/
loading_indicator.rs

1//! Expressive loading indicators with morphing shapes.
2//!
3//! ## Usage
4//!
5//! Indicate brief loading tasks with animated shapes or a known completion
6//! value.
7
8use tessera_ui::{
9    Color, ComputedData, Dp, FrameNanosControl, LayoutResult, MeasurementError, Modifier,
10    accesskit::Role,
11    current_frame_nanos,
12    layout::{MeasureScope, layout},
13    receive_frame_nanos, remember, tessera, use_context,
14};
15
16use crate::{
17    modifier::{ModifierExt as _, SemanticsArgs},
18    pipelines::loading_shape::command::LoadingShapeCommand,
19    res::I18N,
20    shape_def::Shape,
21    theme::MaterialTheme,
22};
23
24const MORPH_INTERVAL_NANOS: u64 = 650_000_000;
25const MORPH_DURATION_NANOS: u64 = 297_000_000;
26const ROTATION_DURATION_NANOS: u64 = 4_666_000_000;
27
28#[derive(Clone, Copy, Debug, PartialEq)]
29struct Pose {
30    sequence: usize,
31    progress: f32,
32    rotation: f32,
33}
34
35fn normalize_progress(progress: f32) -> f32 {
36    if progress.is_nan() {
37        0.0
38    } else {
39        progress.clamp(0.0, 1.0)
40    }
41}
42
43fn spring_value(nanos: u64) -> f32 {
44    let seconds = (nanos / 1_000_000) as f64 / 1000.0;
45    let omega = 200.0_f64.sqrt();
46    let damping = f64::from(0.6_f32);
47    let decay = omega * damping;
48    let frequency = omega * (1.0 - damping * damping).sqrt();
49    (1.0 - (-decay * seconds).exp()
50        * ((frequency * seconds).cos() + decay / frequency * (frequency * seconds).sin()))
51        as f32
52}
53
54fn indeterminate_pose(nanos: u64) -> Pose {
55    let step = nanos / MORPH_INTERVAL_NANOS;
56    let phase = nanos % MORPH_INTERVAL_NANOS;
57    let global_rotation =
58        (nanos % ROTATION_DURATION_NANOS) as f64 / ROTATION_DURATION_NANOS as f64 * 360.0;
59    let (step, progress) = if phase < MORPH_DURATION_NANOS {
60        (step, spring_value(phase))
61    } else {
62        (step + 1, 0.0)
63    };
64    Pose {
65        sequence: (step % 7) as usize,
66        progress,
67        rotation: (progress * 90.0 + ((step + 1) % 4) as f32 * 90.0 + global_rotation as f32)
68            % 360.0,
69    }
70}
71
72#[derive(Clone, Copy)]
73struct AnimationClock {
74    elapsed: u64,
75    frame: u64,
76    running: bool,
77}
78
79impl AnimationClock {
80    fn advance(&mut self, frame: u64) {
81        if self.running {
82            self.elapsed = self
83                .elapsed
84                .saturating_add(frame.saturating_sub(self.frame));
85        }
86        self.frame = frame;
87    }
88
89    fn set_running(&mut self, running: bool, frame: u64) {
90        self.advance(frame);
91        self.running = running;
92    }
93}
94
95#[derive(Clone, Copy, PartialEq)]
96struct LoadingLayout;
97
98impl tessera_ui::LayoutModifierNode for LoadingLayout {
99    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
100        Ok(LayoutResult::new(ComputedData {
101            width: input.parent_constraint().width().clamp(Dp(48.0).to_px()),
102            height: input.parent_constraint().height().clamp(Dp(48.0).to_px()),
103        }))
104    }
105}
106
107#[derive(Clone, Copy, PartialEq)]
108struct LoadingDraw {
109    pose: Pose,
110    color: Color,
111}
112
113impl tessera_ui::DrawModifierNode for LoadingDraw {
114    fn draw(
115        &self,
116        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
117        content: &mut dyn tessera_ui::DrawModifierContent,
118    ) {
119        if self.color.a > 0.0 {
120            ctx.render_input
121                .metadata_mut()
122                .fragment_mut()
123                .push_draw_command(LoadingShapeCommand {
124                    sequence: self.pose.sequence,
125                    progress: self.pose.progress,
126                    rotation: self.pose.rotation,
127                    color: self.color,
128                });
129        }
130        content.draw(ctx.render_input);
131    }
132}
133
134/// # loading_indicator
135///
136/// Show a morphing shape for brief loading tasks or known progress.
137///
138/// ## Usage
139///
140/// Indicate pending content, a refresh, or completion of a short task.
141///
142/// ## Parameters
143///
144/// - `modifier` — placement and size; defaults to 48dp square.
145/// - `progress` — completion in `0.0..=1.0`; omit for a repeating seven-shape
146///   animation.
147/// - `contained` — whether to show a rounded primary-container background.
148/// - `indicator_color` — optional shape color override.
149/// - `container_color`, `container_shape` — optional contained appearance
150///   overrides.
151/// - `paused` — pause the repeating animation and stop frame requests.
152/// - `accessibility_label` — localized task description; defaults to "Loading".
153///
154/// ## Examples
155///
156/// ```
157/// use tessera_components::loading_indicator::loading_indicator;
158/// use tessera_ui::{remember, tessera};
159///
160/// #[tessera]
161/// fn pending_content() {
162///     let completion = remember(|| 0.5_f32);
163///     loading_indicator()
164///         .progress(completion.get())
165///         .contained(true);
166/// }
167/// ```
168#[tessera]
169pub fn loading_indicator(
170    modifier: Option<Modifier>,
171    progress: Option<f32>,
172    contained: Option<bool>,
173    indicator_color: Option<Color>,
174    container_color: Option<Color>,
175    container_shape: Option<Shape>,
176    paused: Option<bool>,
177    #[prop(into)] accessibility_label: Option<String>,
178) {
179    let progress = progress.map(normalize_progress);
180    let running = progress.is_none() && !paused.unwrap_or(false);
181    let now = current_frame_nanos();
182    let clock = remember(|| AnimationClock {
183        elapsed: 0,
184        frame: now,
185        running,
186    });
187    if clock.with(|clock| clock.running) != running {
188        clock.with_mut(|clock| clock.set_running(running, now));
189    }
190    if running {
191        receive_frame_nanos(move |frame| {
192            if !clock.with(|clock| clock.running) {
193                return FrameNanosControl::Stop;
194            }
195            clock.with_mut(|clock| clock.advance(frame));
196            FrameNanosControl::Continue
197        });
198    }
199    let pose = if let Some(progress) = progress {
200        Pose {
201            sequence: 7,
202            progress,
203            rotation: -180.0 * progress,
204        }
205    } else {
206        indeterminate_pose(clock.with(|clock| clock.elapsed))
207    };
208    let scheme = use_context::<MaterialTheme>()
209        .expect("MaterialTheme must be provided")
210        .get()
211        .color_scheme;
212    let contained = contained.unwrap_or(false);
213    let color = indicator_color.unwrap_or(if contained {
214        scheme.on_primary_container
215    } else {
216        scheme.primary
217    });
218    let background = if contained {
219        container_color.unwrap_or(scheme.primary_container)
220    } else {
221        Color::TRANSPARENT
222    };
223    let semantics = SemanticsArgs {
224        role: Some(Role::ProgressIndicator),
225        label: Some(accessibility_label.unwrap_or_else(|| I18N.text("md3-loading").into())),
226        numeric_value: progress.map(f64::from),
227        numeric_range: progress.map(|_| (0.0, 1.0)),
228        ..Default::default()
229    };
230    layout()
231        .modifier(
232            modifier
233                .unwrap_or_default()
234                .background_with_shape(background, container_shape.unwrap_or(Shape::CAPSULE))
235                .clip_to_bounds()
236                .semantics(semantics),
237        )
238        .policy(LoadingLayout)
239        .draw(LoadingDraw { pose, color });
240}
241
242#[cfg(test)]
243mod tests {
244    use tessera_ui::{
245        HeadlessConfig, HeadlessRenderer, State,
246        testing::{TestProbe, layout_test, pump_layout_test_session, start_layout_test_session},
247    };
248
249    use crate::{
250        boxed::boxed,
251        column::column,
252        pipelines::loading_shape::geometry::{center, geometry},
253        theme::material_theme,
254    };
255
256    use super::*;
257
258    #[derive(Clone)]
259    struct Config {
260        progress: Option<f32>,
261        paused: bool,
262    }
263
264    #[tessera]
265    fn fixture(probe: Option<TestProbe<Option<State<Config>>>>) {
266        let config = remember(|| Config {
267            progress: Some(0.0),
268            paused: false,
269        });
270        if let Some(probe) = probe {
271            probe.set(Some(config));
272        }
273        material_theme().child(move || {
274            column().children(move || {
275                for (index, contained) in [false, true].into_iter().enumerate() {
276                    boxed()
277                        .modifier(Modifier::new().semantics(SemanticsArgs {
278                            test_tag: Some(format!("indicator-{index}")),
279                            merge_descendants: true,
280                            ..Default::default()
281                        }))
282                        .children(move || {
283                            let config = config.get();
284                            loading_indicator()
285                                .progress_optional(config.progress)
286                                .paused(config.paused)
287                                .contained(contained)
288                                .accessibility_label(if contained {
289                                    "Refresh"
290                                } else {
291                                    "Download"
292                                });
293                        });
294                }
295            });
296        });
297    }
298
299    #[tessera]
300    fn shape_fixture(probe: TestProbe<Option<State<Pose>>>) {
301        let pose = remember(|| Pose {
302            sequence: 0,
303            progress: 0.0,
304            rotation: 0.0,
305        });
306        probe.set(Some(pose));
307        layout()
308            .modifier(Modifier::new().size(Dp(64.0), Dp(64.0)))
309            .policy(LoadingLayout)
310            .draw(LoadingDraw {
311                pose: pose.get(),
312                color: Color::WHITE,
313            });
314    }
315
316    #[test]
317    fn spring_samples_and_shape_schedule_match_the_reference_animation() {
318        for (millis, expected) in [
319            (50, 0.184660926),
320            (100, 0.527367473),
321            (150, 0.826900542),
322            (200, 1.011090755),
323            (250, 1.086345792),
324        ] {
325            assert!((spring_value(millis * 1_000_000) - expected).abs() < 0.000002);
326        }
327        assert_eq!(indeterminate_pose(0).sequence, 0);
328        assert_eq!(indeterminate_pose(0).rotation, 90.0);
329        assert!(indeterminate_pose(250_000_000).progress > 1.0);
330        assert_eq!(indeterminate_pose(MORPH_DURATION_NANOS).sequence, 1);
331        assert_eq!(indeterminate_pose(MORPH_DURATION_NANOS).progress, 0.0);
332        assert_eq!(indeterminate_pose(MORPH_INTERVAL_NANOS).sequence, 1);
333        assert_eq!(indeterminate_pose(MORPH_INTERVAL_NANOS * 7).sequence, 0);
334    }
335
336    #[test]
337    fn paused_clock_excludes_time_spent_waiting() {
338        let mut clock = AnimationClock {
339            elapsed: 0,
340            frame: 0,
341            running: true,
342        };
343        clock.advance(100_000_000);
344        clock.set_running(false, 150_000_000);
345        clock.advance(5_000_000_000);
346        assert_eq!(clock.elapsed, 150_000_000);
347        clock.set_running(true, 10_000_000_000);
348        clock.advance(10_050_000_000);
349        assert_eq!(clock.elapsed, 200_000_000);
350        assert_eq!(normalize_progress(f32::NAN), 0.0);
351        assert_eq!(normalize_progress(f32::INFINITY), 1.0);
352        assert_eq!(normalize_progress(-1.0), 0.0);
353    }
354
355    #[test]
356    fn both_appearances_default_to_48dp_and_accept_bounded_sizes() {
357        let snapshot = layout_test(|| {
358            fixture();
359        })
360        .viewport_px(160, 160)
361        .run();
362        snapshot.node("indicator-0").size(48, 48);
363        snapshot.node("indicator-1").size(48, 48);
364        let snapshot = layout_test(|| {
365            fixture();
366        })
367        .viewport_px(32, 64)
368        .run();
369        snapshot.node("indicator-0").size(32, 48);
370        let probe = TestProbe::default();
371        let mut session = start_layout_test_session(layout_test(|| {
372            fixture().probe(probe.clone());
373        }));
374        pump_layout_test_session(&mut session, 0);
375        probe
376            .get()
377            .unwrap()
378            .with_mut(|config| config.progress = Some(1.0));
379        pump_layout_test_session(&mut session, 16_666_667)
380            .node("indicator-0")
381            .size(48, 48);
382    }
383
384    #[test]
385    #[ignore = "requires a native GPU adapter"]
386    fn gpu_loading_animation_pauses_reuses_buffers_and_publishes_progress() {
387        let probe = TestProbe::default();
388        let observed = probe.clone();
389        let mut renderer = HeadlessRenderer::new(
390            Box::new(move || {
391                fixture().probe(observed.clone());
392            }),
393            vec![Box::new(crate::TesseraComponents)],
394            HeadlessConfig {
395                width: 128,
396                height: 128,
397                ..Default::default()
398            },
399        )
400        .unwrap();
401        renderer.render_frames(3);
402        let initial = renderer.read_pixels().unwrap();
403        let tree = renderer.accessibility_snapshot().unwrap();
404        let indicators: Vec<_> = tree
405            .nodes
406            .iter()
407            .filter(|(_, node)| node.role() == Role::ProgressIndicator)
408            .collect();
409        assert_eq!(indicators.len(), 2);
410        assert!(
411            indicators
412                .iter()
413                .all(|(_, node)| node.numeric_value() == Some(0.0))
414        );
415        probe
416            .get()
417            .unwrap()
418            .with_mut(|config| config.progress = Some(1.0));
419        renderer.render_frames(3);
420        assert_ne!(initial, renderer.read_pixels().unwrap());
421        probe
422            .get()
423            .unwrap()
424            .with_mut(|config| config.progress = Some(0.0));
425        renderer.render_frames(3);
426        assert_eq!(initial, renderer.read_pixels().unwrap());
427        probe
428            .get()
429            .unwrap()
430            .with_mut(|config| config.progress = None);
431        renderer.render_frames(5);
432        let before = renderer.read_pixels().unwrap();
433        renderer.render_frames(10);
434        assert_ne!(before, renderer.read_pixels().unwrap());
435        let allocations = renderer
436            .graphics_work()
437            .group("loading_shape_instances")
438            .buffer_allocations;
439        renderer.render_frames(180);
440        assert_eq!(
441            allocations,
442            renderer
443                .graphics_work()
444                .group("loading_shape_instances")
445                .buffer_allocations
446        );
447        probe.get().unwrap().with_mut(|config| config.paused = true);
448        renderer.render_frames(3);
449        let paused = renderer.read_pixels().unwrap();
450        let allocations = renderer
451            .graphics_work()
452            .group("loading_shape_instances")
453            .buffer_allocations;
454        renderer.render_frames(180);
455        assert_eq!(paused, renderer.read_pixels().unwrap());
456        assert_eq!(
457            allocations,
458            renderer
459                .graphics_work()
460                .group("loading_shape_instances")
461                .buffer_allocations
462        );
463        let tree = renderer.accessibility_snapshot().unwrap();
464        assert!(
465            tree.nodes
466                .iter()
467                .filter(|(_, node)| node.role() == Role::ProgressIndicator)
468                .all(|(_, node)| node.numeric_value().is_none())
469        );
470    }
471
472    fn reference_coverage(pose: Pose) -> Vec<f32> {
473        let data = geometry();
474        let sequence = data.sequences[pose.sequence];
475        let center = center(sequence, pose.progress);
476        let (sin, cos) = pose.rotation.to_radians().sin_cos();
477        let mut outline = Vec::new();
478        for curve in
479            &data.curves[sequence.offset as usize..(sequence.offset + sequence.count) as usize]
480        {
481            for step in 0..64 {
482                let t = step as f32 / 64.0;
483                let u = 1.0 - t;
484                let weights = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
485                let mut point = [0.0; 2];
486                for axis in 0..2 {
487                    for i in 0..4 {
488                        point[axis] += (curve.start[i][axis]
489                            + (curve.end[i][axis] - curve.start[i][axis]) * pose.progress)
490                            * weights[i];
491                    }
492                    point[axis] = (point[axis] - center[axis]) * 64.0 * sequence.scale;
493                }
494                outline.push([
495                    32.0 + point[0] * cos - point[1] * sin,
496                    32.0 + point[0] * sin + point[1] * cos,
497                ]);
498            }
499        }
500        let mut coverage = vec![0.0; 64 * 64];
501        let mut crossings = Vec::new();
502        for row in 0..64 {
503            for sample in 0..16 {
504                let y = row as f32 + (sample as f32 + 0.5) / 16.0;
505                crossings.clear();
506                for (index, a) in outline.iter().enumerate() {
507                    let b = outline[(index + 1) % outline.len()];
508                    if (a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y) {
509                        crossings.push(a[0] + (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]));
510                    }
511                }
512                crossings.sort_by(f32::total_cmp);
513                for pair in crossings.chunks_exact(2) {
514                    for x in 0..64 {
515                        coverage[row * 64 + x] +=
516                            (pair[1].min(x as f32 + 1.0) - pair[0].max(x as f32)).clamp(0.0, 1.0)
517                                / 16.0;
518                    }
519                }
520            }
521        }
522        coverage
523    }
524
525    #[test]
526    #[ignore = "requires a native GPU adapter"]
527    fn gpu_loading_shapes_match_supersampled_cubic_references() {
528        for sample_count in [1, 4] {
529            let probe = TestProbe::default();
530            let observed = probe.clone();
531            let mut renderer = HeadlessRenderer::new(
532                Box::new(move || {
533                    shape_fixture(observed.clone());
534                }),
535                vec![Box::new(crate::TesseraComponents)],
536                HeadlessConfig {
537                    width: 64,
538                    height: 64,
539                    sample_count,
540                    ..Default::default()
541                },
542            )
543            .unwrap();
544            renderer.render_frames(2);
545            for sequence in 0..8 {
546                for progress in [0.0, 0.5, 1.086] {
547                    let pose = Pose {
548                        sequence,
549                        progress,
550                        rotation: 37.0,
551                    };
552                    probe.get().unwrap().set(pose);
553                    renderer.render_frames(2);
554                    let pixels = renderer.read_pixels().unwrap();
555                    let reference = reference_coverage(pose);
556                    let total: f32 = reference.iter().sum();
557                    if let Some(directory) = std::env::var_os("TESSERA_LOADING_QA_DIR") {
558                        let directory = std::path::Path::new(&directory);
559                        std::fs::create_dir_all(directory).unwrap();
560                        let comparison = image::RgbaImage::from_fn(192, 64, |x, y| {
561                            let i = (y as usize * 64) + x as usize % 64;
562                            let actual = pixels[i * 4 + 3] as f32 / 255.0;
563                            let expected = reference[i];
564                            let value = if x < 64 {
565                                actual
566                            } else if x < 128 {
567                                expected
568                            } else {
569                                (actual - expected).abs() * 4.0
570                            };
571                            let channel = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
572                            image::Rgba([channel, channel, channel, 255])
573                        });
574                        comparison
575                            .save(directory.join(format!(
576                                "loading-shape-{sequence}-{progress}-msaa{sample_count}.png"
577                            )))
578                            .unwrap();
579                    }
580                    let error: f32 = pixels
581                        .chunks_exact(4)
582                        .zip(reference)
583                        .map(|(pixel, expected)| (pixel[3] as f32 / 255.0 - expected).abs())
584                        .sum();
585                    assert!(
586                        error / total < 0.025,
587                        "sequence {sequence}, progress {progress}, MSAA {sample_count}: coverage error {}",
588                        error / total
589                    );
590                }
591            }
592        }
593    }
594}