Skip to main content

crabshell_ui/
animation.rs

1//! Animations: values that move toward a target over a [`Transition`],
2//! and the clock the platform advances only while one is running, so a
3//! shell where nothing moves wakes for nothing.
4
5use std::cell::{Cell, RefCell};
6use std::panic::{self, AssertUnwindSafe};
7use std::rc::Rc;
8use std::time::Instant;
9
10use crabshell_reactive::{ReadSignal, untrack};
11
12use crate::cx::Cx;
13use crate::geometry::Point;
14use crate::input::panic_message;
15use crate::style::Color;
16
17/// How an animation moves from its start to its end: what fraction of
18/// the way it has gone at each fraction of its time.
19#[derive(Clone, Copy, Debug, Default, PartialEq)]
20pub enum Easing {
21  /// The same speed all the way.
22  Linear,
23  /// Starts slow.
24  InQuad,
25  /// Ends slow.
26  OutQuad,
27  /// Starts and ends slow.
28  InOutQuad,
29  /// Starts slower.
30  InCubic,
31  /// Ends slower. The default: it settles gently, as most interface
32  /// motion should.
33  #[default]
34  OutCubic,
35  /// Starts and ends slower.
36  InOutCubic,
37  /// A cubic Bézier from (0, 0) to (1, 1) through the control points
38  /// `(x1, y1)` and `(x2, y2)`, as CSS's `cubic-bezier`. The x values are
39  /// kept within 0 to 1.
40  Bezier(f32, f32, f32, f32),
41}
42
43impl Easing {
44  /// How far along the animation is, from 0 to 1 (a Bézier may overshoot
45  /// either end), when `t` of its time has passed. `t` is kept within 0
46  /// to 1.
47  pub fn value_at(self, t: f32) -> f32 {
48    let t = if t.is_nan() { 1.0 } else { t.clamp(0.0, 1.0) };
49    match self {
50      Easing::Linear => t,
51      Easing::InQuad => t * t,
52      Easing::OutQuad => 1.0 - (1.0 - t) * (1.0 - t),
53      Easing::InOutQuad if t < 0.5 => 2.0 * t * t,
54      Easing::InOutQuad => 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0,
55      Easing::InCubic => t * t * t,
56      Easing::OutCubic => 1.0 - (1.0 - t).powi(3),
57      Easing::InOutCubic if t < 0.5 => 4.0 * t * t * t,
58      Easing::InOutCubic => 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0,
59      Easing::Bezier(x1, y1, x2, y2) => bezier(t, x1.clamp(0.0, 1.0), y1, x2.clamp(0.0, 1.0), y2),
60    }
61  }
62}
63
64/// The y of a cubic Bézier through (0, 0), `(x1, y1)`, `(x2, y2)` and
65/// (1, 1) at the point whose x is `x`, found by bisection: x grows with
66/// the curve's parameter when both control x values are within 0 to 1.
67fn bezier(x: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
68  let at = |a: f32, b: f32, s: f32| {
69    let r = 1.0 - s;
70    3.0 * r * r * s * a + 3.0 * r * s * s * b + s * s * s
71  };
72  let (mut low, mut high) = (0.0_f32, 1.0_f32);
73  for _ in 0..32 {
74    let middle = (low + high) / 2.0;
75    if at(x1, x2, middle) < x {
76      low = middle;
77    } else {
78      high = middle;
79    }
80  }
81  at(y1, y2, (low + high) / 2.0)
82}
83
84/// How long an animation takes and how it moves. Copy it into styles and
85/// constants like a color.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct Transition {
88  /// The time it takes, in milliseconds. 0 jumps straight to the end.
89  pub ms: u32,
90  /// How it moves.
91  pub easing: Easing,
92}
93
94impl Transition {
95  /// A transition of `ms` milliseconds, easing out.
96  pub const fn new(ms: u32) -> Transition {
97    Transition {
98      ms,
99      easing: Easing::OutCubic,
100    }
101  }
102
103  /// The same transition moving as `easing` says.
104  pub const fn easing(self, easing: Easing) -> Transition {
105    Transition { easing, ..self }
106  }
107
108  /// How far along an animation that started at `start` is at `now`,
109  /// eased, and whether it has ended.
110  pub(crate) fn progress(self, start: Instant, now: Instant) -> (f32, bool) {
111    if self.ms == 0 {
112      return (1.0, true);
113    }
114    let elapsed = now.saturating_duration_since(start).as_secs_f32() * 1000.0;
115    let t = elapsed / self.ms as f32;
116    if t >= 1.0 {
117      (1.0, true)
118    } else {
119      (self.easing.value_at(t), false)
120    }
121  }
122}
123
124/// A value an animation can move between two of: `lerp` gives the value
125/// `t` of the way from `self` to `to`, where `t` may stray a little past 0
126/// or 1 for an easing that overshoots.
127pub trait Animate: Clone + PartialEq + 'static {
128  /// The value `t` of the way from `self` to `to`.
129  fn lerp(&self, to: &Self, t: f32) -> Self;
130}
131
132impl Animate for f32 {
133  fn lerp(&self, to: &f32, t: f32) -> f32 {
134    self + (to - self) * t
135  }
136}
137
138impl Animate for Point {
139  fn lerp(&self, to: &Point, t: f32) -> Point {
140    Point::new(self.x.lerp(&to.x, t), self.y.lerp(&to.y, t))
141  }
142}
143
144impl Animate for Color {
145  fn lerp(&self, to: &Color, t: f32) -> Color {
146    let channel = |a: u8, b: u8| {
147      f32::from(a)
148        .lerp(&f32::from(b), t)
149        .round()
150        .clamp(0.0, 255.0) as u8
151    };
152    Color::rgba(
153      channel(self.r, to.r),
154      channel(self.g, to.g),
155      channel(self.b, to.b),
156      channel(self.a, to.a),
157    )
158  }
159}
160
161/// One running animation: advanced to a time, it says whether it still
162/// runs.
163type Step = Box<dyn FnMut(Instant) -> bool>;
164
165thread_local! {
166  /// Everything is on one thread, so the clock is too. Each animation is
167  /// named for log lines.
168  static RUNNING: RefCell<Vec<(Rc<str>, Step)>> = const { RefCell::new(Vec::new()) };
169  /// When the clock last ticked.
170  static LAST_TICK: Cell<Option<Instant>> = const { Cell::new(None) };
171}
172
173/// Starts `step`, named `label` for log lines, at the next
174/// [`advance_animations`].
175pub(crate) fn start(label: Rc<str>, step: Step) {
176  RUNNING.with(|running| running.borrow_mut().push((label, step)));
177}
178
179/// When an animation starting now should count from: the clock's last
180/// tick while it runs, so one that replaces another moves at the very
181/// next tick, or `None` when it is still, so the first tick is time 0.
182pub(crate) fn start_time() -> Option<Instant> {
183  if animating() {
184    LAST_TICK.with(Cell::get)
185  } else {
186    None
187  }
188}
189
190/// Whether an animation is running, so the platform should advance the
191/// clock soon. `false` means nothing moves and nothing needs waking.
192pub fn animating() -> bool {
193  RUNNING.with(|running| !running.borrow().is_empty())
194}
195
196/// Advances every running animation to `now`, writing their values, and
197/// returns whether any still runs. Each one starts at the first call after
198/// it began, so `now` there is its time 0. One that panics is logged and
199/// dropped.
200pub fn advance_animations(now: Instant) -> bool {
201  LAST_TICK.with(|last| last.set(Some(now)));
202  let steps = RUNNING.with(|running| std::mem::take(&mut *running.borrow_mut()));
203  let mut kept = Vec::with_capacity(steps.len());
204  for (label, mut step) in steps {
205    match panic::catch_unwind(AssertUnwindSafe(|| step(now))) {
206      Ok(true) => kept.push((label, step)),
207      Ok(false) => drop_isolated(&label, step),
208      Err(payload) => {
209        tracing::error!(
210          "animation {label} panicked and stops: {}",
211          panic_message(payload.as_ref())
212        );
213        drop_isolated(&label, step);
214      }
215    }
216  }
217  RUNNING.with(|running| {
218    // Animations started while these ran go after them.
219    let mut running = running.borrow_mut();
220    kept.append(&mut running);
221    *running = kept;
222    if running.is_empty() {
223      // The clock stops, so the next animation counts from its own first
224      // tick, not this one.
225      LAST_TICK.with(|last| last.set(None));
226    }
227    !running.is_empty()
228  })
229}
230
231/// Drops a finished step, and the user's values it holds, isolated.
232fn drop_isolated(label: &str, step: Step) {
233  if let Err(payload) = panic::catch_unwind(AssertUnwindSafe(move || drop(step))) {
234    tracing::error!(
235      "dropping animation {label} panicked: {}",
236      panic_message(payload.as_ref())
237    );
238  }
239}
240
241impl Cx {
242  /// A value that follows `target`, moving to each new one over
243  /// `transition` instead of jumping. It starts at `target`'s first value.
244  /// A new target while it moves starts a new animation from where it is.
245  /// The animation goes when this context's owner does.
246  ///
247  /// ```
248  /// use crabshell_ui::{Cx, Transition};
249  ///
250  /// fn slide(cx: &Cx) {
251  ///     let open = cx.signal(false);
252  ///     let offset = cx.animate(move || if open.get() { 0.0 } else { -200.0 }, Transition::new(200));
253  ///     // Read `offset.get()` in a binding, such as a style's width.
254  /// }
255  /// ```
256  #[track_caller]
257  pub fn animate<T: Animate>(
258    &self,
259    mut target: impl FnMut() -> T + 'static,
260    transition: Transition,
261  ) -> ReadSignal<T> {
262    let label: Rc<str> = format!("cx.animate at {}", std::panic::Location::caller()).into();
263    let first = untrack(&mut target);
264    let value = self.signal(first.clone());
265    let mut last = first;
266    // Bumped by each new target, so the animation it replaces stops.
267    let generation = Rc::new(Cell::new(0_u64));
268    self.effect(move || {
269      let to = target();
270      if to == last {
271        return;
272      }
273      last = to.clone();
274      let Some(from) = untrack(|| value.try_get()) else {
275        return;
276      };
277      let current = generation.get() + 1;
278      generation.set(current);
279      // No time to take: the new value shows in this flush.
280      if transition.ms == 0 {
281        value.try_set(to);
282        return;
283      }
284      let generation = Rc::clone(&generation);
285      let mut started = start_time();
286      start(
287        Rc::clone(&label),
288        Box::new(move |now| {
289          if generation.get() != current {
290            return false;
291          }
292          let begun = *started.get_or_insert(now);
293          let (t, ended) = transition.progress(begun, now);
294          let next = if ended { to.clone() } else { from.lerp(&to, t) };
295          // A value whose owner is gone stops its animation.
296          value.try_set(next) && !ended
297        }),
298      );
299    });
300    value.read_only()
301  }
302}