Skip to main content

tessera_components/
wavy_progress.rs

1//! Expressive progress indicators with moving waves.
2//!
3//! ## Usage
4//!
5//! Show task completion or ongoing work with an animated waveform.
6
7mod circular;
8mod geometry;
9mod shapes;
10
11#[cfg(test)]
12mod tests;
13
14use tessera_ui::{
15    Color, ComputedData, Dp, FrameNanosControl, LayoutResult, MeasurementError, Modifier,
16    accesskit::Role,
17    current_frame_nanos,
18    layout::{MeasureScope, layout},
19    receive_frame_nanos, remember, tessera, use_context,
20};
21
22use crate::{
23    modifier::{ModifierExt as _, Padding, SemanticsArgs},
24    progress::{ProgressStrokeCap, cubic_bezier_easing},
25    theme::MaterialTheme,
26};
27
28use self::{
29    circular::{Cache, CircularWaveDraw, vertices},
30    geometry::LinearWaveDraw,
31};
32
33const AMPLITUDE_DURATION: u64 = 500_000_000;
34const INDETERMINATE_DURATION: u64 = 1_750_000_000;
35
36/// Material defaults for expressive progress indicators.
37pub struct WavyProgressIndicatorDefaults;
38
39impl WavyProgressIndicatorDefaults {
40    /// Default linear container width.
41    pub const LINEAR_WIDTH: Dp = Dp(240.0);
42    /// Default linear container height, including the wave.
43    pub const LINEAR_HEIGHT: Dp = Dp(10.0);
44    /// Default active and inactive stroke width.
45    pub const STROKE_WIDTH: Dp = Dp(4.0);
46    /// Default wavelength for known completion.
47    pub const LINEAR_DETERMINATE_WAVELENGTH: Dp = Dp(40.0);
48    /// Default wavelength for ongoing work.
49    pub const LINEAR_INDETERMINATE_WAVELENGTH: Dp = Dp(20.0);
50    /// Default visible spacing between active and inactive strokes.
51    pub const TRACK_GAP: Dp = Dp(4.0);
52    /// Default stop indicator diameter.
53    pub const STOP_SIZE: Dp = Dp(4.0);
54    /// Default circular container diameter.
55    pub const CIRCULAR_SIZE: Dp = Dp(48.0);
56    /// Preferred circular wavelength; the number of waves is rounded to fit.
57    pub const CIRCULAR_WAVELENGTH: Dp = Dp(15.0);
58
59    /// Returns the default wave amplitude for known completion, flattening the
60    /// first 10% and final 5% of progress.
61    pub fn indicator_amplitude(progress: f32) -> f32 {
62        let progress = normalized(progress);
63        if progress <= 0.1 || progress >= 0.95 {
64            0.0
65        } else {
66            1.0
67        }
68    }
69}
70
71#[derive(Clone, Copy, PartialEq)]
72struct CircularWaveLayout;
73
74impl tessera_ui::LayoutModifierNode for CircularWaveLayout {
75    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
76        Ok(LayoutResult::new(ComputedData {
77            width: input
78                .parent_constraint()
79                .width()
80                .clamp(WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_px()),
81            height: input
82                .parent_constraint()
83                .height()
84                .clamp(WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_px()),
85        }))
86    }
87}
88
89fn circular_indeterminate(elapsed: u64) -> (f32, f32) {
90    let millis = (elapsed % 6_000_000_000) as f32 / 1_000_000.0;
91    let progress = crate::progress::circular_indeterminate_progress(millis);
92    let rotation = millis / 6000.0 * 1080.0
93        + crate::progress::circular_additional_rotation_degrees(millis)
94        + 90.0;
95    (progress, rotation.to_radians())
96}
97
98/// # circular_wavy_progress_indicator
99///
100/// Show completion or ongoing work with an expressive circular waveform.
101///
102/// ## Usage
103///
104/// Indicate a loading operation or task completion in a compact space.
105///
106/// ## Parameters
107///
108/// - `progress` — completion in `0.0..=1.0`; omit for ongoing work.
109/// - `modifier` — placement and size; defaults to 48dp square.
110/// - `color`, `track_color` — optional active and inactive colors.
111/// - `stroke_width`, `track_stroke_width` — independent widths; default to 4dp.
112/// - `stroke_cap`, `track_stroke_cap` — independent caps; default to rounded.
113/// - `amplitude` — wave fraction in `0.0..=1.0`; defaults to full height,
114///   flattening at known progress at or below 10% and at or above 95%.
115/// - `wavelength` — preferred wavelength, default 15dp. The wave count is
116///   rounded to fit, with a minimum of five and a maximum of 1024.
117/// - `wave_speed` — wave travel per second; defaults to one wavelength. Zero
118///   stops wave travel while indeterminate progress can continue.
119/// - `gap_size` — visible spacing between active and inactive arcs; default
120///   4dp.
121/// - `paused` — freeze motion and stop requesting animation frames; changed
122///   appearance parameters still apply immediately.
123/// - `reverse_direction` — mirror the indicator for counterclockwise flow.
124/// - `accessibility_label`, `accessibility_description` — localized task
125///   details.
126///
127/// ## Examples
128///
129/// ```
130/// use tessera_components::{
131///     theme::material_theme,
132///     wavy_progress::circular_wavy_progress_indicator,
133/// };
134/// use tessera_ui::tessera;
135///
136/// #[tessera]
137/// fn content() {
138///     material_theme().child(|| {
139///         circular_wavy_progress_indicator()
140///             .progress(0.5)
141///             .paused(true);
142///     });
143/// }
144/// # tessera_ui::testing::layout_test(content).run();
145/// ```
146#[tessera]
147pub fn circular_wavy_progress_indicator(
148    progress: Option<f32>,
149    modifier: Option<Modifier>,
150    color: Option<Color>,
151    track_color: Option<Color>,
152    stroke_width: Option<Dp>,
153    track_stroke_width: Option<Dp>,
154    stroke_cap: Option<ProgressStrokeCap>,
155    track_stroke_cap: Option<ProgressStrokeCap>,
156    amplitude: Option<f32>,
157    wavelength: Option<Dp>,
158    wave_speed: Option<Dp>,
159    gap_size: Option<Dp>,
160    paused: Option<bool>,
161    reverse_direction: Option<bool>,
162    #[prop(into)] accessibility_label: Option<String>,
163    #[prop(into)] accessibility_description: Option<String>,
164) {
165    use tessera_ui::FocusModifierExt as _;
166
167    let progress = progress.map(normalized);
168    let wavelength =
169        nonnegative(wavelength.unwrap_or(WavyProgressIndicatorDefaults::CIRCULAR_WAVELENGTH));
170    let speed = nonnegative(wave_speed.unwrap_or(wavelength));
171    let stroke = nonnegative(stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH));
172    let amplitude = if wavelength.0 == 0.0 {
173        0.0
174    } else {
175        amplitude.map(normalized).unwrap_or_else(|| {
176            progress
177                .map(WavyProgressIndicatorDefaults::indicator_amplitude)
178                .unwrap_or(1.0)
179        })
180    };
181    let measured_size = remember(|| None::<[f32; 2]>);
182    let size = measured_size
183        .get()
184        .unwrap_or([WavyProgressIndicatorDefaults::CIRCULAR_SIZE.to_pixels_f32(); 2]);
185    let vertex_count = vertices(
186        size[0],
187        size[1],
188        stroke.to_pixels_f32(),
189        wavelength.to_pixels_f32(),
190    );
191    let period_nanos = (wavelength.0 > 0.0 && speed.0 > 0.0).then(|| {
192        ((wavelength.0 / speed.0 * 1000.0 * vertex_count as f64)
193            .round()
194            .max(50.0)
195            * 1_000_000.0)
196            .min(u64::MAX as f64) as u64
197    });
198    let motion = Motion {
199        amplitude,
200        period_nanos,
201        indeterminate: progress.is_none(),
202        paused: paused.unwrap_or(false),
203        circular: true,
204    };
205    let now = current_frame_nanos();
206    let clock = remember(|| Clock::new(motion, now));
207    if clock.with(|clock| clock.motion != motion) {
208        clock.with_mut(|clock| clock.configure(motion, now));
209    }
210    if clock.with(Clock::needs_frames) {
211        receive_frame_nanos(move |frame| {
212            clock.with_mut(|clock| clock.advance(frame));
213            if clock.with(Clock::needs_frames) {
214                FrameNanosControl::Continue
215            } else {
216                FrameNanosControl::Stop
217            }
218        });
219    }
220    let cache = remember(Cache::default).get();
221    let clock = clock.get();
222    let (fraction, rotation) = progress
223        .map(|progress| (progress, 0.0))
224        .unwrap_or_else(|| circular_indeterminate(clock.elapsed));
225    let scheme = use_context::<MaterialTheme>()
226        .expect("MaterialTheme must be provided")
227        .get()
228        .color_scheme;
229    layout()
230        .modifier(
231            modifier
232                .unwrap_or_default()
233                .clip_to_bounds()
234                .semantics(SemanticsArgs {
235                    role: Some(Role::ProgressIndicator),
236                    label: accessibility_label,
237                    description: accessibility_description,
238                    numeric_value: progress.map(f64::from),
239                    numeric_range: progress.map(|_| (0.0, 1.0)),
240                    ..Default::default()
241                })
242                .on_layout_coordinates(move |coordinates: tessera_ui::LayoutCoordinates| {
243                    let size = [
244                        coordinates.local_size.width.to_f32(),
245                        coordinates.local_size.height.to_f32(),
246                    ];
247                    if measured_size.get() != Some(size) {
248                        measured_size.set(Some(size));
249                    }
250                }),
251        )
252        .policy(CircularWaveLayout)
253        .draw(CircularWaveDraw {
254            cache,
255            wavelength,
256            progress: fraction,
257            amplitude: clock.amplitude(),
258            phase: if clock.amplitude() > 0.0 && period_nanos.is_some() {
259                clock.phase.fract() as f32
260            } else {
261                0.0
262            },
263            rotation,
264            stroke_width: stroke,
265            track_stroke_width: nonnegative(
266                track_stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
267            ),
268            gap: nonnegative(gap_size.unwrap_or(WavyProgressIndicatorDefaults::TRACK_GAP)),
269            stroke_cap: stroke_cap.unwrap_or_default(),
270            track_stroke_cap: track_stroke_cap.unwrap_or_default(),
271            color: color.unwrap_or(scheme.primary),
272            track_color: track_color.unwrap_or(scheme.secondary_container),
273            reverse: reverse_direction.unwrap_or(false),
274        });
275}
276
277fn normalized(value: f32) -> f32 {
278    if value.is_nan() {
279        0.0
280    } else {
281        value.clamp(0.0, 1.0)
282    }
283}
284
285fn nonnegative(value: Dp) -> Dp {
286    if value.0.is_finite() {
287        Dp(value.0.max(0.0))
288    } else {
289        Dp::ZERO
290    }
291}
292
293#[derive(Clone, Copy, PartialEq)]
294struct Motion {
295    amplitude: f32,
296    period_nanos: Option<u64>,
297    indeterminate: bool,
298    paused: bool,
299    circular: bool,
300}
301
302#[derive(Clone, Copy)]
303struct Clock {
304    motion: Motion,
305    elapsed: u64,
306    frame: u64,
307    phase: f64,
308    amplitude_origin: f32,
309    amplitude_start: u64,
310}
311
312impl Clock {
313    fn new(motion: Motion, frame: u64) -> Self {
314        Self {
315            motion,
316            elapsed: 0,
317            frame,
318            phase: 0.0,
319            amplitude_origin: motion.amplitude,
320            amplitude_start: 0,
321        }
322    }
323
324    fn amplitude(&self) -> f32 {
325        let t =
326            self.elapsed.saturating_sub(self.amplitude_start) as f64 / AMPLITUDE_DURATION as f64;
327        if t >= 1.0 || self.amplitude_origin == self.motion.amplitude {
328            return self.motion.amplitude;
329        }
330        let easing = if self.amplitude_origin < self.motion.amplitude {
331            cubic_bezier_easing(t as f32, 0.2, 0.0, 0.0, 1.0)
332        } else {
333            cubic_bezier_easing(t as f32, 0.3, 0.0, 0.8, 0.15)
334        };
335        self.amplitude_origin + (self.motion.amplitude - self.amplitude_origin) * easing
336    }
337
338    fn needs_frames(&self) -> bool {
339        !self.motion.paused
340            && (self.motion.indeterminate
341                || (self.amplitude_origin != self.motion.amplitude
342                    && self.elapsed.saturating_sub(self.amplitude_start) < AMPLITUDE_DURATION)
343                || (self.amplitude() > 0.0 && self.motion.period_nanos.is_some()))
344    }
345
346    fn advance(&mut self, frame: u64) {
347        let delta = frame.saturating_sub(self.frame);
348        if !self.motion.paused
349            && (!self.motion.circular || self.amplitude() > 0.0)
350            && let Some(period) = self.motion.period_nanos
351        {
352            self.phase += delta as f64 / period as f64;
353            if !self.motion.circular {
354                self.phase = self.phase.fract();
355            }
356        }
357        if self.needs_frames() {
358            self.elapsed = self.elapsed.saturating_add(delta);
359        }
360        self.frame = frame;
361    }
362
363    fn configure(&mut self, motion: Motion, frame: u64) {
364        self.advance(frame);
365        if motion.amplitude != self.motion.amplitude {
366            self.amplitude_origin = if motion.paused {
367                motion.amplitude
368            } else {
369                self.amplitude()
370            };
371            self.amplitude_start = self.elapsed;
372        }
373        if motion.period_nanos.is_none() {
374            self.phase = 0.0;
375        }
376        self.motion = motion;
377    }
378}
379
380fn indeterminate_fractions(elapsed: u64) -> [[f32; 2]; 2] {
381    let millis = (elapsed % INDETERMINATE_DURATION) as f64 / 1_000_000.0;
382    let keyframe = |delay: f64, duration: f64| {
383        let t = ((millis - delay) / duration).clamp(0.0, 1.0) as f32;
384        if t == 0.0 || t == 1.0 {
385            t
386        } else {
387            cubic_bezier_easing(t, 0.3, 0.0, 0.8, 0.15)
388        }
389    };
390    [
391        [keyframe(250.0, 1000.0), keyframe(0.0, 1000.0)],
392        [keyframe(900.0, 850.0), keyframe(650.0, 850.0)],
393    ]
394}
395
396#[derive(Clone, Copy, PartialEq)]
397struct LinearWaveLayout;
398
399impl tessera_ui::LayoutModifierNode for LinearWaveLayout {
400    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
401        Ok(LayoutResult::new(ComputedData {
402            width: input
403                .parent_constraint()
404                .width()
405                .clamp(WavyProgressIndicatorDefaults::LINEAR_WIDTH.to_px()),
406            height: input
407                .parent_constraint()
408                .height()
409                .clamp(WavyProgressIndicatorDefaults::LINEAR_HEIGHT.to_px()),
410        }))
411    }
412}
413
414/// # linear_wavy_progress_indicator
415///
416/// Show completion or ongoing work with an expressive linear waveform.
417///
418/// ## Usage
419///
420/// Indicate a download, upload, or background operation with known or unknown
421/// duration.
422///
423/// ## Parameters
424///
425/// - `progress` — completion in `0.0..=1.0`; omit for an indeterminate
426///   animation.
427/// - `modifier` — placement and size; defaults to 240dp by 10dp.
428/// - `color`, `track_color` — optional active and inactive colors.
429/// - `stroke_width`, `track_stroke_width` — independent stroke widths; default
430///   to 4dp.
431/// - `stroke_cap`, `track_stroke_cap` — active and inactive caps; default to
432///   rounded.
433/// - `amplitude` — wave height fraction in `0.0..=1.0`; defaults to full
434///   height, flattening at progress at or below 10% and at or above 95%.
435/// - `wavelength` — wave length; defaults to 40dp for known progress and 20dp
436///   otherwise.
437/// - `wave_speed` — distance traveled per second; defaults to one wavelength.
438///   Zero stops wave travel while indeterminate progress can continue.
439/// - `gap_size`, `stop_size` — track spacing and determinate stop diameter;
440///   both default to 4dp, and a zero stop diameter hides the stop.
441/// - `paused` — freeze motion and stop animation frame requests; changed
442///   appearance parameters still apply immediately.
443/// - `reverse_direction` — rotate the indicator by 180 degrees for reverse
444///   flow.
445/// - `accessibility_label`, `accessibility_description` — localized task
446///   information.
447///
448/// ## Examples
449///
450/// ```
451/// use tessera_components::{
452///     theme::material_theme,
453///     wavy_progress::{WavyProgressIndicatorDefaults, linear_wavy_progress_indicator},
454/// };
455/// use tessera_ui::tessera;
456///
457/// assert_eq!(
458///     WavyProgressIndicatorDefaults::indicator_amplitude(0.05),
459///     0.0
460/// );
461/// assert_eq!(WavyProgressIndicatorDefaults::indicator_amplitude(0.5), 1.0);
462/// #[tessera]
463/// fn content() {
464///     material_theme().child(|| {
465///         linear_wavy_progress_indicator().progress(0.5).paused(true);
466///     });
467/// }
468/// # tessera_ui::testing::layout_test(content).run();
469/// ```
470#[tessera]
471pub fn linear_wavy_progress_indicator(
472    progress: Option<f32>,
473    modifier: Option<Modifier>,
474    color: Option<Color>,
475    track_color: Option<Color>,
476    stroke_width: Option<Dp>,
477    track_stroke_width: Option<Dp>,
478    stroke_cap: Option<ProgressStrokeCap>,
479    track_stroke_cap: Option<ProgressStrokeCap>,
480    amplitude: Option<f32>,
481    wavelength: Option<Dp>,
482    wave_speed: Option<Dp>,
483    gap_size: Option<Dp>,
484    stop_size: Option<Dp>,
485    paused: Option<bool>,
486    reverse_direction: Option<bool>,
487    #[prop(into)] accessibility_label: Option<String>,
488    #[prop(into)] accessibility_description: Option<String>,
489) {
490    let progress = progress.map(normalized);
491    let amplitude = amplitude.map(normalized).unwrap_or_else(|| {
492        progress
493            .map(WavyProgressIndicatorDefaults::indicator_amplitude)
494            .unwrap_or(1.0)
495    });
496    let wavelength = nonnegative(wavelength.unwrap_or(if progress.is_some() {
497        WavyProgressIndicatorDefaults::LINEAR_DETERMINATE_WAVELENGTH
498    } else {
499        WavyProgressIndicatorDefaults::LINEAR_INDETERMINATE_WAVELENGTH
500    }));
501    let speed = nonnegative(wave_speed.unwrap_or(wavelength));
502    let period_nanos = (wavelength.0 > 0.0 && speed.0 > 0.0).then(|| {
503        ((wavelength.0 / speed.0 * 1000.0).round().max(50.0) * 1_000_000.0).min(u64::MAX as f64)
504            as u64
505    });
506    let motion = Motion {
507        amplitude,
508        period_nanos,
509        indeterminate: progress.is_none(),
510        paused: paused.unwrap_or(false),
511        circular: false,
512    };
513    let now = current_frame_nanos();
514    let clock = remember(|| Clock::new(motion, now));
515    if clock.with(|clock| clock.motion != motion) {
516        clock.with_mut(|clock| clock.configure(motion, now));
517    }
518    if clock.with(Clock::needs_frames) {
519        receive_frame_nanos(move |frame| {
520            clock.with_mut(|clock| clock.advance(frame));
521            if clock.with(Clock::needs_frames) {
522                FrameNanosControl::Continue
523            } else {
524                FrameNanosControl::Stop
525            }
526        });
527    }
528    let clock = clock.get();
529    let fractions = progress
530        .map(|progress| [[0.0, progress], [0.0, 0.0]])
531        .unwrap_or_else(|| indeterminate_fractions(clock.elapsed));
532    let scheme = use_context::<MaterialTheme>()
533        .expect("MaterialTheme must be provided")
534        .get()
535        .color_scheme;
536    let semantics = SemanticsArgs {
537        role: Some(Role::ProgressIndicator),
538        label: accessibility_label,
539        description: accessibility_description,
540        numeric_value: progress.map(f64::from),
541        numeric_range: progress.map(|_| (0.0, 1.0)),
542        bounds_padding: Some(Padding::vertical(Dp(10.0))),
543        ..Default::default()
544    };
545    layout()
546        .modifier(
547            modifier
548                .unwrap_or_default()
549                .clip_to_bounds()
550                .semantics(semantics),
551        )
552        .policy(LinearWaveLayout)
553        .draw(LinearWaveDraw {
554            fractions,
555            count: if progress.is_some() { 1 } else { 2 },
556            amplitude: clock.amplitude(),
557            phase: clock.phase as f32,
558            wavelength,
559            stroke_width: nonnegative(
560                stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
561            ),
562            track_stroke_width: nonnegative(
563                track_stroke_width.unwrap_or(WavyProgressIndicatorDefaults::STROKE_WIDTH),
564            ),
565            gap: nonnegative(gap_size.unwrap_or(WavyProgressIndicatorDefaults::TRACK_GAP)),
566            stop: if progress.is_some() {
567                nonnegative(stop_size.unwrap_or(WavyProgressIndicatorDefaults::STOP_SIZE))
568            } else {
569                Dp::ZERO
570            },
571            stroke_cap: stroke_cap.unwrap_or_default(),
572            track_stroke_cap: track_stroke_cap.unwrap_or_default(),
573            color: color.unwrap_or(scheme.primary),
574            track_color: track_color.unwrap_or(scheme.secondary_container),
575            reverse: reverse_direction.unwrap_or(false),
576        });
577}