Skip to main content

azul_core/
animation.rs

1//! DOM-morph animation: interpolation core, FLIP geometry, and the keyed
2//! animation store.
3//!
4//! # Model
5//!
6//! Animation compiles to keyed timer callbacks writing into the top cascade layer
7//! (`user_overridden_properties`). This module handles the math and bookkeeping
8//! across frames:
9//!
10//! * [`Spring`] / [`AnimChannel`]: How a single value transitions from `from` to `to`,
11//!   with interruption as a first-class operation ([`AnimChannel::retarget`]).
12//! * [`flip`]: The First/Last inversion that turns a layout change into a
13//!   composited transform, saving relayouts.
14//! * [`AnimationManager`]: The keyed store - keys are reconciliation identities
15//!   so that retargeting finds the in-flight state instead of overlapping.
16//!
17//! # Curve vs. Spring
18//!
19//! Easing curves are functions of normalized time. Interrupting them discards velocity,
20//! causing snapping. Springs integrate from the current `(value, velocity)`, allowing
21//! smooth retargeting.
22
23use alloc::{collections::BTreeMap, vec::Vec};
24
25use azul_css::props::basic::animation::AnimationInterpolationFunction;
26
27use crate::{
28    diff::{calculate_reconciliation_key, NodeMove},
29    dom::NodeData,
30    geom::LogicalRect,
31    id::NodeId,
32    styled_dom::NodeHierarchyItem,
33};
34
35/// Re-export of [`SpringCurve`].
36pub use azul_css::props::basic::animation::SpringCurve as Spring;
37
38/// A single animated value over time (e.g., an x-coordinate or opacity).
39///
40/// Complex animations are created by combining multiple channels. For example,
41/// moving an element in 2D requires four channels: x and y translation, and x and y scale.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct AnimChannel {
44    /// Where the value started. Re-seeded on every retarget.
45    pub from: f32,
46    /// Where it is heading.
47    pub to: f32,
48    /// The current value to write into the cascade.
49    pub current: f32,
50    /// Units per second. Carried across retargets.
51    pub velocity: f32,
52    /// Seconds since the animation began (curve mode only).
53    pub elapsed_secs: f32,
54    /// How it is driven.
55    pub mode: InterpolationMode,
56    /// Latched once the channel arrives.
57    finished: bool,
58}
59
60/// Specifies how an animated value transitions over time.
61///
62/// It can either be driven by a time-based curve (duration-based) or by a
63/// physics-based spring (velocity and target-based).
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub enum InterpolationMode {
66    /// Duration-based easing. Not interruptible without a discontinuity.
67    Curve {
68        /// The CSS easing curve.
69        function: AnimationInterpolationFunction,
70        /// Total duration in seconds. Zero means apply instantly.
71        duration_secs: f32,
72    },
73    /// Physics-based. Interruptible with velocity continuity.
74    Spring(Spring),
75}
76
77impl Default for InterpolationMode {
78    fn default() -> Self {
79        Self::Spring(Spring::SMOOTH)
80    }
81}
82
83impl AnimChannel {
84    /// A channel that eases `from → to` over `duration_secs`.
85    #[must_use]
86    pub const fn curve(
87        from: f32,
88        to: f32,
89        function: AnimationInterpolationFunction,
90        duration_secs: f32,
91    ) -> Self {
92        Self {
93            from,
94            to,
95            current: from,
96            velocity: 0.0,
97            elapsed_secs: 0.0,
98            mode: InterpolationMode::Curve {
99                function,
100                duration_secs,
101            },
102            finished: false,
103        }
104    }
105
106    /// A channel that springs `from → to`.
107    #[must_use]
108    pub const fn spring(from: f32, to: f32, spring: Spring) -> Self {
109        Self {
110            from,
111            to,
112            current: from,
113            velocity: 0.0,
114            elapsed_secs: 0.0,
115            mode: InterpolationMode::Spring(spring),
116            finished: false,
117        }
118    }
119
120    /// Advance by `dt` seconds and return the new current value.
121    pub fn tick(&mut self, dt: f32) -> f32 {
122        if self.finished {
123            return self.current;
124        }
125        match self.mode {
126            InterpolationMode::Curve {
127                function,
128                duration_secs,
129            } => {
130                if duration_secs <= 0.0 {
131                    self.current = self.to;
132                    self.velocity = 0.0;
133                    self.finished = true;
134                    return self.current;
135                }
136                self.elapsed_secs += dt.max(0.0);
137                let linear_t = (self.elapsed_secs / duration_secs).clamp(0.0, 1.0);
138                let eased = ease(function, linear_t);
139                let previous = self.current;
140                // Suboptimal flops allowed for bit-reproducibility across builds.
141                #[allow(clippy::suboptimal_flops)]
142                {
143                    self.current = self.from + (self.to - self.from) * eased;
144                }
145                // Track velocity even on curves to ensure continuous handover to springs.
146                self.velocity = if dt > 0.0 {
147                    (self.current - previous) / dt
148                } else {
149                    0.0
150                };
151                if linear_t >= 1.0 {
152                    self.current = self.to;
153                    self.finished = true;
154                }
155            }
156            InterpolationMode::Spring(spring) => {
157                let (value, velocity) = spring.step(self.current, self.to, self.velocity, dt);
158                self.current = value;
159                self.velocity = velocity;
160                if spring.is_settled(value, self.to, velocity) {
161                    self.current = self.to;
162                    self.velocity = 0.0;
163                    self.finished = true;
164                }
165            }
166        }
167        self.current
168    }
169
170    /// Whether this channel has arrived at the target value and can be dropped (animation finished)
171    #[must_use]
172    pub const fn is_finished(&self) -> bool {
173        self.finished
174    }
175
176    /// Aim at a new target without losing the current value or velocity.
177    ///
178    /// This allows smooth retargeting mid-flight.
179    pub fn retarget(&mut self, new_to: f32) {
180        if (self.to - new_to).abs() < f32::EPSILON && !self.finished {
181            return; // already heading there; do not restart the clock
182        }
183        self.from = self.current;
184        self.to = new_to;
185        self.elapsed_secs = 0.0;
186        self.finished = false;
187        // Velocity is deliberately not reset to allow smooth retargeting.
188    }
189}
190
191/// Evaluate a CSS easing curve at `t ∈ [0, 1]`.
192///
193/// `Ease` and the cubic-beziers use the same evaluator; the named curves are
194/// their standard control points.
195#[must_use]
196pub fn ease(function: AnimationInterpolationFunction, t: f32) -> f32 {
197    let t = t.clamp(0.0, 1.0);
198    match function {
199        AnimationInterpolationFunction::Linear => t,
200        // The CSS keyword control points.
201        AnimationInterpolationFunction::Ease => cubic_bezier_y(0.25, 0.1, 0.25, 1.0, t),
202        AnimationInterpolationFunction::EaseIn => cubic_bezier_y(0.42, 0.0, 1.0, 1.0, t),
203        AnimationInterpolationFunction::EaseOut => cubic_bezier_y(0.0, 0.0, 0.58, 1.0, t),
204        AnimationInterpolationFunction::EaseInOut => cubic_bezier_y(0.42, 0.0, 0.58, 1.0, t),
205        AnimationInterpolationFunction::Spring(_) => {
206            crate::diagnostics::emit(String::from(
207                "Warning: Spring evaluated as an easing curve. This is a misusage.",
208            ));
209            // Degrade gracefully to ease-in-out to prevent crashes.
210            cubic_bezier_y(0.42, 0.0, 0.58, 1.0, t)
211        }
212        // The 0,0 and 1,1 points are hardcoded directly into the math equation itself,
213        // so only ctrl_1 and ctrl_2 are needed.
214        AnimationInterpolationFunction::CubicBezier(curve) => cubic_bezier_y(
215            curve.ctrl_1.x,
216            curve.ctrl_1.y,
217            curve.ctrl_2.x,
218            curve.ctrl_2.y,
219            t,
220        ),
221    }
222}
223
224/// Calculates the y value of a CSS timing bezier given an x (time progress).
225///
226/// Unlike a standard bezier evaluation that calculates `(x, y)` from a curve parameter `t`,
227/// CSS easing requires finding `y` (eased progress) for a specific `x` (linear time).
228/// To do this, we must first reverse-engineer `t` from `x`.
229///
230/// We try a fast math shortcut to find `t`. If the curve is too flat, we fall back
231/// to a slower, safer method to find the exact `t`, which is then used to calculate `y`.
232fn cubic_bezier_y(x1: f32, y1: f32, x2: f32, y2: f32, x: f32) -> f32 {
233    const NEWTON_ITERATIONS: usize = 4;
234    const BISECTION_ITERATIONS: usize = 12;
235    const EPSILON: f32 = 1e-5;
236
237    // allowed for bit-reproducibility across builds
238    #[allow(clippy::suboptimal_flops)]
239    let bezier = |a: f32, b: f32, t: f32| {
240        let inv = 1.0 - t;
241        3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t
242    };
243    #[allow(clippy::suboptimal_flops)]
244    let bezier_slope = |a: f32, b: f32, t: f32| {
245        let inv = 1.0 - t;
246        3.0 * inv * inv * a + 6.0 * inv * t * (b - a) + 3.0 * t * t * (1.0 - b)
247    };
248
249    if x <= 0.0 {
250        return 0.0;
251    }
252    if x >= 1.0 {
253        return 1.0;
254    }
255
256    let mut t = x;
257    for _ in 0..NEWTON_ITERATIONS {
258        let error = bezier(x1, x2, t) - x;
259        if error.abs() < EPSILON {
260            return bezier(y1, y2, t);
261        }
262        let slope = bezier_slope(x1, x2, t);
263        if slope.abs() < EPSILON {
264            break;
265        }
266        t -= error / slope;
267    }
268
269    let (mut low, mut high) = (0.0_f32, 1.0_f32);
270    let mut t = x;
271    for _ in 0..BISECTION_ITERATIONS {
272        let value = bezier(x1, x2, t);
273        if (value - x).abs() < EPSILON {
274            break;
275        }
276        if value < x {
277            low = t;
278        } else {
279            high = t;
280        }
281        t = (low + high) * 0.5;
282    }
283    bezier(y1, y2, t)
284}
285
286/// The inverted transform of a FLIP move.
287///
288/// "First" is the element's original position. "Last" is its new position after layout.
289/// FLIP animation works by placing the element at Last, then applying a transform to
290/// make it look like it's at First, and finally animating that transform down to zero.
291/// This allows elements to move smoothly on the GPU without triggering expensive layout
292/// recalculations on every frame.
293#[derive(Debug, Clone, Copy, PartialEq, Default)]
294#[repr(C)]
295pub struct FlipTransform {
296    /// Horizontal offset, logical px.
297    pub translate_x: f32,
298    /// Vertical offset, logical px.
299    pub translate_y: f32,
300    /// Horizontal scale, 1.0 = unchanged.
301    pub scale_x: f32,
302    /// Vertical scale, 1.0 = unchanged.
303    pub scale_y: f32,
304}
305
306impl FlipTransform {
307    /// The no-op transform.
308    pub const IDENTITY: Self = Self {
309        translate_x: 0.0,
310        translate_y: 0.0,
311        scale_x: 1.0,
312        scale_y: 1.0,
313    };
314
315    /// Whether this is close enough to identity.
316    #[must_use]
317    pub fn is_identity(&self) -> bool {
318        self.translate_x.abs() < 0.01
319            && self.translate_y.abs() < 0.01
320            && (self.scale_x - 1.0).abs() < 0.001
321            && (self.scale_y - 1.0).abs() < 0.001
322    }
323}
324
325/// Compute the FLIP transform to move from the `first` rect to the `last` rect.
326///
327/// If the `last` rect has a zero size, the scale falls back to 1.
328/// The function only calculates changes in position, avoiding content distortion.
329#[must_use]
330pub fn flip(first: LogicalRect, last: LogicalRect) -> FlipTransform {
331    let _ = (first.size, last.size); // sizes are layout's job, not the animation's
332    FlipTransform {
333        translate_x: first.origin.x - last.origin.x,
334        translate_y: first.origin.y - last.origin.y,
335        scale_x: 1.0,
336        scale_y: 1.0,
337    }
338}
339
340/// Which presence class an animation belongs to.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum AnimClass {
343    /// Node exists in the new DOM only.
344    Enter,
345    /// Node existed in the old DOM only. Needs exit-retention to be visible.
346    Exit,
347    /// Node exists in both, at different geometry.
348    Move,
349}
350
351/// Identity of an animation across frames.
352///
353/// Uses the reconciliation key (`.with_key()` / `#id` / structural hash)
354/// to remain stable across frames, unlike `NodeId`.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
356#[repr(C)]
357pub struct AnimKey(pub u64);
358
359/// One in-flight animation: the FLIP channels plus opacity.
360#[derive(Debug, Clone, Copy, PartialEq)]
361pub struct ActiveAnim {
362    /// What kind of presence change started this.
363    pub class: AnimClass,
364    /// Horizontal offset channel.
365    pub translate_x: AnimChannel,
366    /// Vertical offset channel.
367    pub translate_y: AnimChannel,
368    /// Horizontal scale channel.
369    pub scale_x: AnimChannel,
370    /// Vertical scale channel.
371    pub scale_y: AnimChannel,
372    /// Opacity channel.
373    pub opacity: AnimChannel,
374}
375
376impl ActiveAnim {
377    /// A move: start at the FLIP inversion, animate to identity.
378    #[must_use]
379    pub const fn move_from_flip(flip: FlipTransform, mode: InterpolationMode) -> Self {
380        Self {
381            class: AnimClass::Move,
382            translate_x: channel(flip.translate_x, 0.0, mode),
383            translate_y: channel(flip.translate_y, 0.0, mode),
384            scale_x: channel(flip.scale_x, 1.0, mode),
385            scale_y: channel(flip.scale_y, 1.0, mode),
386            opacity: channel(1.0, 1.0, mode),
387        }
388    }
389
390    /// An enter: slide in from `(from_x, from_y)` to identity, full opacity and size.
391    #[must_use]
392    pub const fn enter_slide(from_x: f32, from_y: f32, mode: InterpolationMode) -> Self {
393        Self {
394            class: AnimClass::Enter,
395            translate_x: channel(from_x, 0.0, mode),
396            translate_y: channel(from_y, 0.0, mode),
397            scale_x: channel(1.0, 1.0, mode),
398            scale_y: channel(1.0, 1.0, mode),
399            opacity: channel(1.0, 1.0, mode),
400        }
401    }
402
403    /// An exit: slide out from identity to `(to_x, to_y)`, full opacity and size.
404    /// Only visible with exit-retention.
405    ///
406    /// Reversing a presence animation in flight will retarget from the current
407    /// values with velocity preserved.
408    pub fn retarget_presence(&mut self, class: AnimClass, to_x: f32, to_y: f32) {
409        self.class = class;
410        self.translate_x.retarget(to_x);
411        self.translate_y.retarget(to_y);
412        self.scale_x.retarget(1.0);
413        self.scale_y.retarget(1.0);
414        self.opacity.retarget(1.0);
415    }
416
417    #[must_use]
418    pub const fn exit_slide(to_x: f32, to_y: f32, mode: InterpolationMode) -> Self {
419        Self {
420            class: AnimClass::Exit,
421            translate_x: channel(0.0, to_x, mode),
422            translate_y: channel(0.0, to_y, mode),
423            scale_x: channel(1.0, 1.0, mode),
424            scale_y: channel(1.0, 1.0, mode),
425            opacity: channel(1.0, 1.0, mode),
426        }
427    }
428
429    /// Advance every channel by `dt` seconds.
430    pub fn tick(&mut self, dt: f32) {
431        self.translate_x.tick(dt);
432        self.translate_y.tick(dt);
433        self.scale_x.tick(dt);
434        self.scale_y.tick(dt);
435        self.opacity.tick(dt);
436    }
437
438    /// True once every channel has arrived.
439    #[must_use]
440    pub const fn is_finished(&self) -> bool {
441        self.translate_x.is_finished()
442            && self.translate_y.is_finished()
443            && self.scale_x.is_finished()
444            && self.scale_y.is_finished()
445            && self.opacity.is_finished()
446    }
447
448    /// The transform to write this frame.
449    #[must_use]
450    pub const fn current_transform(&self) -> FlipTransform {
451        FlipTransform {
452            translate_x: self.translate_x.current,
453            translate_y: self.translate_y.current,
454            scale_x: self.scale_x.current,
455            scale_y: self.scale_y.current,
456        }
457    }
458
459    /// The opacity to write this frame.
460    #[must_use]
461    pub const fn current_opacity(&self) -> f32 {
462        self.opacity.current
463    }
464
465    /// Re-aim at a new FLIP target, preserving position and velocity.
466    pub fn retarget_move(&mut self, flip: FlipTransform) {
467        // Seed toward identity from the current position.
468        self.translate_x.retarget(0.0);
469        self.translate_y.retarget(0.0);
470        self.scale_x.retarget(1.0);
471        self.scale_y.retarget(1.0);
472        // Fold the freshly measured offset in, rather than snapping to it.
473        self.translate_x.current += flip.translate_x;
474        self.translate_y.current += flip.translate_y;
475    }
476}
477
478const fn channel(from: f32, to: f32, mode: InterpolationMode) -> AnimChannel {
479    match mode {
480        InterpolationMode::Curve {
481            function,
482            duration_secs,
483        } => AnimChannel::curve(from, to, function, duration_secs),
484        InterpolationMode::Spring(spring) => AnimChannel::spring(from, to, spring),
485    }
486}
487
488/// The keyed store of in-flight animations.
489///
490/// Holds only what must outlive a frame.
491#[derive(Debug, Clone, Default)]
492pub struct AnimationManager {
493    active: BTreeMap<AnimKey, ActiveAnim>,
494}
495
496impl AnimationManager {
497    /// An empty manager.
498    #[must_use]
499    pub const fn new() -> Self {
500        Self {
501            active: BTreeMap::new(),
502        }
503    }
504
505    /// How many animations are in flight.
506    #[must_use]
507    pub fn len(&self) -> usize {
508        self.active.len()
509    }
510
511    /// Whether anything is animating (i.e. whether a frame needs scheduling).
512    #[must_use]
513    pub fn is_empty(&self) -> bool {
514        self.active.is_empty()
515    }
516
517    /// Start a move, or retarget one already in flight under this key.
518    pub fn start_or_retarget_move(
519        &mut self,
520        key: AnimKey,
521        flip: FlipTransform,
522        mode: InterpolationMode,
523    ) {
524        if let Some(existing) = self.active.get_mut(&key) {
525            existing.retarget_move(flip);
526        } else {
527            self.active
528                .insert(key, ActiveAnim::move_from_flip(flip, mode));
529        }
530    }
531
532    /// Start an enter animation, unless this key is already animating.
533    pub fn start_enter(&mut self, key: AnimKey, from: (f32, f32), mode: InterpolationMode) {
534        self.active
535            .entry(key)
536            .or_insert_with(|| ActiveAnim::enter_slide(from.0, from.1, mode));
537    }
538
539    /// Start an exit animation.
540    ///
541    /// If an animation is already in flight, the channels retarget from their
542    /// current value with velocity preserved.
543    pub fn start_exit(&mut self, key: AnimKey, to: (f32, f32), mode: InterpolationMode) {
544        match self.active.get_mut(&key) {
545            Some(anim) => anim.retarget_presence(AnimClass::Exit, to.0, to.1),
546            None => {
547                self.active
548                    .insert(key, ActiveAnim::exit_slide(to.0, to.1, mode));
549            }
550        }
551    }
552
553    /// Mutable access to an in-flight animation.
554    pub fn get_mut(&mut self, key: AnimKey) -> Option<&mut ActiveAnim> {
555        self.active.get_mut(&key)
556    }
557
558    /// Read the current state for a key.
559    #[must_use]
560    pub fn get(&self, key: AnimKey) -> Option<&ActiveAnim> {
561        self.active.get(&key)
562    }
563
564    /// Every in-flight animation with its key.
565    pub fn iter(&self) -> impl Iterator<Item = (AnimKey, &ActiveAnim)> {
566        self.active.iter().map(|(k, v)| (*k, v))
567    }
568
569    /// Advance every animation and drop the ones that arrived.
570    ///
571    /// Returns the keys that finished this tick.
572    pub fn tick(&mut self, dt: f32) -> Vec<AnimKey> {
573        let mut finished = Vec::new();
574        for (key, anim) in &mut self.active {
575            anim.tick(dt);
576            if anim.is_finished() {
577                finished.push(*key);
578            }
579        }
580        for key in &finished {
581            self.active.remove(key);
582        }
583        finished
584    }
585
586    /// Drop an animation without letting it finish.
587    pub fn cancel(&mut self, key: AnimKey) -> Option<ActiveAnim> {
588        self.active.remove(&key)
589    }
590}
591
592/// Turn the diff's correspondence map into `(key, First, Last)` triples.
593///
594/// Pairs missing either rect are dropped.
595pub fn correspondences_from_moves<F, L>(
596    node_moves: &[NodeMove],
597    new_node_data: &[NodeData],
598    new_hierarchy: &[NodeHierarchyItem],
599    first_rect: F,
600    last_rect: L,
601) -> Vec<(AnimKey, LogicalRect, LogicalRect)>
602where
603    F: Fn(NodeId) -> Option<LogicalRect>,
604    L: Fn(NodeId) -> Option<LogicalRect>,
605{
606    let mut out = Vec::new();
607    for m in node_moves {
608        let (Some(first), Some(last)) = (first_rect(m.old_node_id), last_rect(m.new_node_id))
609        else {
610            continue;
611        };
612        if m.new_node_id.index() >= new_node_data.len() {
613            continue; // stale correspondence; the new tree does not have this node
614        }
615        let key = AnimKey(calculate_reconciliation_key(
616            new_node_data,
617            new_hierarchy,
618            m.new_node_id,
619        ));
620        out.push((key, first, last));
621    }
622    out
623}
624
625/// The `AnimKey` -> current `NodeId` mapping for this frame's correspondences.
626///
627/// Bridges the reconciliation identity and per-frame `NodeId`.
628#[must_use]
629pub fn anim_keys_for_moves(
630    node_moves: &[NodeMove],
631    new_node_data: &[NodeData],
632    new_hierarchy: &[NodeHierarchyItem],
633) -> Vec<(AnimKey, NodeId)> {
634    node_moves
635        .iter()
636        .filter(|m| m.new_node_id.index() < new_node_data.len())
637        .map(|m| {
638            (
639                AnimKey(calculate_reconciliation_key(
640                    new_node_data,
641                    new_hierarchy,
642                    m.new_node_id,
643                )),
644                m.new_node_id,
645            )
646        })
647        .collect()
648}
649
650/// Seed (or retarget) a FLIP move for every correspondence whose geometry moved.
651///
652/// Returns how many animations were started or retargeted.
653pub fn seed_moves<I>(
654    manager: &mut AnimationManager,
655    correspondences: I,
656    mode: InterpolationMode,
657) -> usize
658where
659    I: IntoIterator<Item = (AnimKey, LogicalRect, LogicalRect)>,
660{
661    let mut seeded = 0;
662    for (key, first, last) in correspondences {
663        let transform = flip(first, last);
664        if transform.is_identity() {
665            continue;
666        }
667        manager.start_or_retarget_move(key, transform, mode);
668        seeded += 1;
669    }
670    seeded
671}
672
673#[cfg(test)]
674#[path = "animation_test.rs"]
675mod animation_test;