1use 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#[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}