Skip to main content

qframe/animation/
play.rs

1//! Playing an animation: which frame is shown at a moment, and in which colour.
2
3use std::time::Duration;
4
5use super::{CellAnimation, ColorMode, Playback};
6use crate::color::Rgb;
7use crate::theme::{Paint, Theme};
8
9/// What an animation shows at one moment.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CellFrame {
12    /// The frame shown, counted from 0.
13    pub index: usize,
14    /// Its colour, or `None` when the frame has no colour and the widget gave none.
15    pub color: Option<Rgb>,
16    /// Whether a [`Playback::Once`] animation has played to its end.
17    pub finished: bool,
18    /// How long until the frame changes; `None` when it stands still.
19    pub next: Option<Duration>,
20    /// Whether the colour moves between frame changes (a pulse or a blend), so the cell needs
21    /// smooth redraws.
22    pub smooth: bool,
23}
24
25/// Where a playing animation is: the frame shown, the frame after it and how far into it.
26struct Position {
27    index: usize,
28    following: usize,
29    /// How far into the frame, 0 to 1.
30    progress: f32,
31    remaining: Duration,
32    finished: bool,
33}
34
35impl CellAnimation {
36    /// The frame and colour at `now`.
37    ///
38    /// `since` is when the animation started (a time on the same clock as `now`, such as
39    /// [`PaintCx::now`](crate::widget::PaintCx::now)); `None` shows the rest frame standing still,
40    /// which is what reduced motion asks for. Looping spinners pass `Duration::ZERO`, so every
41    /// spinner on screen turns in step. Frames without a colour, and `$fg` in colour
42    /// expressions, take `fg`; a colour naming a token the theme lacks is treated the same way.
43    /// Pulses breathe over the theme's `motion.pulse-period` on the `now` clock.
44    #[must_use]
45    pub fn sample(&self, theme: &Theme, fg: Option<Rgb>, now: Duration, since: Option<Duration>) -> CellFrame {
46        if self.frames.is_empty() {
47            return CellFrame { index: 0, color: fg, finished: true, next: None, smooth: false };
48        }
49        let Some(since) = since else {
50            let index = self.rest_index();
51            let color = self.paint(index, theme, fg).map(|paint| match paint {
52                // Standing still, a pulse shows its second colour: the one it breathes towards.
53                Paint::Pulse(_, strong) => strong,
54                Paint::Solid(color) => color,
55            });
56            return CellFrame { index, color, finished: true, next: None, smooth: false };
57        };
58        let position = self.position(theme, now.saturating_sub(since));
59        let phase = pulse_phase(theme, now);
60        let here = self.paint(position.index, theme, fg);
61        let mut smooth = here.is_some_and(Paint::is_animated);
62        let mut color = here.map(|paint| paint.at(phase));
63        if self.colors == ColorMode::Blend && position.following != position.index {
64            let there = self.paint(position.following, theme, fg);
65            smooth |= there.is_some_and(Paint::is_animated);
66            if let (Some(from), Some(to)) = (color, there.map(|paint| paint.at(phase))) {
67                smooth |= from != to;
68                color = Some(from.mix(to, position.progress));
69            }
70        }
71        let next = (!position.finished).then_some(position.remaining);
72        CellFrame { index: position.index, color, finished: position.finished, next, smooth }
73    }
74
75    /// The paint of frame `index`: its own colour, else `fg`.
76    fn paint(&self, index: usize, theme: &Theme, fg: Option<Rgb>) -> Option<Paint> {
77        let own = self.frames.get(index).and_then(|frame| frame.color.as_ref());
78        match own {
79            Some(color) => {
80                let widget = fg.or_else(|| theme.color("text")).unwrap_or(Rgb::new(0, 0, 0));
81                color.resolve(theme, widget).ok().or(fg.map(Paint::Solid))
82            }
83            None => fg.map(Paint::Solid),
84        }
85    }
86
87    /// How long frame `index` is shown, in whole milliseconds and at least one, as the stepped
88    /// motion of the rest of the framework counts it.
89    fn frame_millis(&self, index: usize, theme: &Theme) -> u128 {
90        let time = self.frames.get(index).and_then(|frame| frame.duration).unwrap_or(self.frame_time);
91        time.resolve(&theme.motion()).as_millis().max(1)
92    }
93
94    /// The order frames are shown in one pass.
95    fn sequence(&self) -> impl Iterator<Item = usize> + Clone + '_ {
96        let count = self.frames.len();
97        let back = if self.playback == Playback::Bounce { count.saturating_sub(1) } else { 0 };
98        (0..count).chain((1..back).rev())
99    }
100
101    fn position(&self, theme: &Theme, elapsed: Duration) -> Position {
102        let sequence: Vec<(usize, u128)> =
103            self.sequence().map(|index| (index, self.frame_millis(index, theme))).collect();
104        let total: u128 = sequence.iter().map(|(_, millis)| millis).sum();
105        let elapsed = elapsed.as_millis();
106        let last = self.frames.len() - 1;
107        if self.playback == Playback::Once && elapsed >= total {
108            return Position { index: last, following: last, progress: 0.0, remaining: Duration::ZERO, finished: true };
109        }
110        let mut into = elapsed % total.max(1);
111        for (step, (index, millis)) in sequence.iter().enumerate() {
112            if into < *millis {
113                let following = match sequence.get(step + 1) {
114                    Some((next, _)) => *next,
115                    None if self.playback == Playback::Once => *index,
116                    None => sequence[0].0,
117                };
118                let remaining = Duration::from_millis(u64::try_from(millis - into).unwrap_or(u64::MAX));
119                let progress = into as f32 / *millis as f32;
120                return Position { index: *index, following, progress, remaining, finished: false };
121            }
122            into -= millis;
123        }
124        // `into` is below the total, so the loop always returns; stay on the last frame otherwise.
125        Position { index: last, following: last, progress: 0.0, remaining: Duration::ZERO, finished: true }
126    }
127}
128
129/// Where the theme's pulse is at `now`, `0.0..1.0`, counted in whole milliseconds like
130/// [`PaintCx::cycle`](crate::widget::PaintCx::cycle).
131fn pulse_phase(theme: &Theme, now: Duration) -> f32 {
132    let period = theme.motion().pulse_period.as_millis().max(1);
133    (now.as_millis() % period) as f32 / period as f32
134}