Skip to main content

slt/
anim.rs

1//! Animation primitives: tweens, springs, keyframes, sequences, and staggers.
2//!
3//! All animations are tick-based — call the `value()` method each frame with
4//! the current [`Context::tick`](crate::Context::tick) to advance. No timers
5//! or threads involved.
6
7use std::f64::consts::PI;
8
9/// Linear interpolation between `a` and `b` at position `t` (0.0..=1.0).
10///
11/// Values of `t` outside `[0, 1]` are not clamped; use an easing function
12/// first if you need clamping.
13pub fn lerp(a: f64, b: f64, t: f64) -> f64 {
14    a + (b - a) * t
15}
16
17/// Linear easing: constant rate from 0.0 to 1.0.
18pub fn ease_linear(t: f64) -> f64 {
19    clamp01(t)
20}
21
22/// Quadratic ease-in: slow start, fast end.
23pub fn ease_in_quad(t: f64) -> f64 {
24    let t = clamp01(t);
25    t * t
26}
27
28/// Quadratic ease-out: fast start, slow end.
29pub fn ease_out_quad(t: f64) -> f64 {
30    let t = clamp01(t);
31    1.0 - (1.0 - t) * (1.0 - t)
32}
33
34/// Quadratic ease-in-out: slow start, fast middle, slow end.
35pub fn ease_in_out_quad(t: f64) -> f64 {
36    let t = clamp01(t);
37    if t < 0.5 {
38        2.0 * t * t
39    } else {
40        1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
41    }
42}
43
44/// Cubic ease-in: slow start, fast end (stronger than quadratic).
45pub fn ease_in_cubic(t: f64) -> f64 {
46    let t = clamp01(t);
47    t * t * t
48}
49
50/// Cubic ease-out: fast start, slow end (stronger than quadratic).
51pub fn ease_out_cubic(t: f64) -> f64 {
52    let t = clamp01(t);
53    1.0 - (1.0 - t).powi(3)
54}
55
56/// Cubic ease-in-out: slow start, fast middle, slow end (stronger than quadratic).
57pub fn ease_in_out_cubic(t: f64) -> f64 {
58    let t = clamp01(t);
59    if t < 0.5 {
60        4.0 * t * t * t
61    } else {
62        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
63    }
64}
65
66/// Elastic ease-out: overshoots the target and oscillates before settling.
67pub fn ease_out_elastic(t: f64) -> f64 {
68    let t = clamp01(t);
69    if t == 0.0 {
70        0.0
71    } else if t == 1.0 {
72        1.0
73    } else {
74        let c4 = (2.0 * PI) / 3.0;
75        2f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
76    }
77}
78
79/// Bounce ease-out: simulates a ball bouncing before coming to rest.
80pub fn ease_out_bounce(t: f64) -> f64 {
81    let t = clamp01(t);
82    let n1 = 7.5625;
83    let d1 = 2.75;
84
85    if t < 1.0 / d1 {
86        n1 * t * t
87    } else if t < 2.0 / d1 {
88        let t = t - 1.5 / d1;
89        n1 * t * t + 0.75
90    } else if t < 2.5 / d1 {
91        let t = t - 2.25 / d1;
92        n1 * t * t + 0.9375
93    } else {
94        let t = t - 2.625 / d1;
95        n1 * t * t + 0.984_375
96    }
97}
98
99/// Linear interpolation between two values over a duration, with optional easing.
100///
101/// A `Tween` advances from `from` to `to` over `duration_ticks` render ticks.
102/// Call [`Tween::value`] each frame with the current tick to get the
103/// interpolated value. The tween is inactive until [`Tween::reset`] is called
104/// with a start tick.
105///
106/// # Example
107///
108/// ```
109/// use slt::Tween;
110/// use slt::anim::ease_out_quad;
111///
112/// let mut tween = Tween::new(0.0, 100.0, 20).easing(ease_out_quad);
113/// tween.reset(0);
114///
115/// let v = tween.value(10); // roughly halfway, eased
116/// assert!(v > 50.0);       // ease-out is faster at the start
117/// ```
118pub struct Tween {
119    from: f64,
120    to: f64,
121    duration_ticks: u64,
122    start_tick: u64,
123    easing: fn(f64) -> f64,
124    done: bool,
125    on_complete: Option<Box<dyn FnMut()>>,
126}
127
128impl Tween {
129    /// Create a new tween from `from` to `to` over `duration_ticks` ticks.
130    ///
131    /// Uses linear easing by default. Call [`Tween::easing`] to change it.
132    /// The tween starts paused; call [`Tween::reset`] with the current tick
133    /// before reading values.
134    pub fn new(from: f64, to: f64, duration_ticks: u64) -> Self {
135        Self {
136            from,
137            to,
138            duration_ticks,
139            start_tick: 0,
140            easing: ease_linear,
141            done: false,
142            on_complete: None,
143        }
144    }
145
146    /// Set the easing function used to interpolate the value.
147    ///
148    /// Any function with signature `fn(f64) -> f64` that maps `[0, 1]` to
149    /// `[0, 1]` works. The nine built-in options are in this module.
150    pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
151        self.easing = f;
152        self
153    }
154
155    /// Register a callback that runs once when the tween completes.
156    pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
157        self.on_complete = Some(Box::new(f));
158        self
159    }
160
161    /// Return the interpolated value at the given `tick`.
162    ///
163    /// Returns `to` immediately if the tween has finished or `duration_ticks`
164    /// is zero. Marks the tween as done once `tick >= start_tick + duration_ticks`.
165    pub fn value(&mut self, tick: u64) -> f64 {
166        if self.done {
167            return self.to;
168        }
169
170        if self.duration_ticks == 0 {
171            self.done = true;
172            if let Some(cb) = &mut self.on_complete {
173                cb();
174            }
175            return self.to;
176        }
177
178        let elapsed = tick.wrapping_sub(self.start_tick);
179        if elapsed >= self.duration_ticks {
180            self.done = true;
181            if let Some(cb) = &mut self.on_complete {
182                cb();
183            }
184            return self.to;
185        }
186
187        let progress = elapsed as f64 / self.duration_ticks as f64;
188        let eased = (self.easing)(clamp01(progress));
189        lerp(self.from, self.to, eased)
190    }
191
192    /// Returns `true` if the tween has reached its end value.
193    pub fn is_done(&self) -> bool {
194        self.done
195    }
196
197    /// Restart the tween, treating `tick` as the new start time.
198    pub fn reset(&mut self, tick: u64) {
199        self.start_tick = tick;
200        self.done = false;
201    }
202}
203
204/// Default animation duration in ticks used by
205/// [`Context::animate_bool`](crate::Context::animate_bool) and
206/// [`Context::animate_value`](crate::Context::animate_value) when no explicit
207/// duration is supplied.
208///
209/// 12 ticks at the default 60 Hz tick rate is roughly 200 ms — short enough
210/// to feel snappy, long enough to read as motion.
211pub const DEFAULT_ANIMATE_TICKS: u64 = 12;
212
213/// Internal state used by [`Context::animate_value`] /
214/// [`Context::animate_bool`] to drive an implicit
215/// `Tween` keyed in `Context::named_states`.
216///
217/// Stores the most recently seen `target` so the tween can smoothly retarget
218/// when the caller changes the goal mid-animation. Not part of the public
219/// API; users keying their own animation state should construct a [`Tween`]
220/// directly.
221pub(crate) struct AnimState {
222    pub(crate) tween: Tween,
223    pub(crate) last_target: f64,
224}
225
226impl AnimState {
227    /// Initialize with the tween already at its target so the first sample
228    /// has no visible animation pop.
229    pub(crate) fn new(target: f64, tick: u64) -> Self {
230        let mut tween = Tween::new(target, target, 0);
231        tween.reset(tick);
232        Self {
233            tween,
234            last_target: target,
235        }
236    }
237
238    /// Sample the current value, retargeting if the goal changed.
239    ///
240    /// On retarget the new tween starts from the current interpolated value,
241    /// avoiding a visible jump when the target flips mid-flight. A
242    /// `duration_ticks` of 0 snaps to the new target immediately.
243    pub(crate) fn sample(&mut self, target: f64, duration_ticks: u64, tick: u64) -> f64 {
244        // Compare bit patterns so two NaNs are treated as equal — avoids
245        // re-resetting forever if a caller threads NaN through.
246        if self.last_target.to_bits() != target.to_bits() {
247            let current = self.tween.value(tick);
248            self.tween = Tween::new(current, target, duration_ticks);
249            self.tween.reset(tick);
250            self.last_target = target;
251        }
252        self.tween.value(tick)
253    }
254}
255
256/// Defines how an animation behaves after reaching its end.
257#[non_exhaustive]
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum LoopMode {
260    /// Play once, then stay at the final value.
261    Once,
262    /// Restart from the beginning each cycle.
263    Repeat,
264    /// Alternate forward and backward each cycle.
265    PingPong,
266}
267
268#[derive(Clone, Copy)]
269struct KeyframeStop {
270    position: f64,
271    value: f64,
272}
273
274/// Multi-stop keyframe animation over a fixed tick duration.
275///
276/// `Keyframes` is similar to CSS `@keyframes`: define multiple stops in the
277/// normalized `[0.0, 1.0]` timeline, then sample the value with
278/// [`Keyframes::value`] using the current render tick.
279///
280/// Stops are sorted by position when sampled. Each segment between adjacent
281/// stops can use its own easing function.
282///
283/// # Example
284///
285/// ```
286/// use slt::anim::{ease_in_cubic, ease_out_quad, Keyframes, LoopMode};
287///
288/// let mut keyframes = Keyframes::new(60)
289///     .stop(0.0, 0.0)
290///     .stop(0.5, 100.0)
291///     .stop(1.0, 40.0)
292///     .segment_easing(0, ease_out_quad)
293///     .segment_easing(1, ease_in_cubic)
294///     .loop_mode(LoopMode::PingPong);
295///
296/// keyframes.reset(10);
297/// let _ = keyframes.value(40);
298/// ```
299pub struct Keyframes {
300    duration_ticks: u64,
301    start_tick: u64,
302    stops: Vec<KeyframeStop>,
303    default_easing: fn(f64) -> f64,
304    segment_easing: Vec<fn(f64) -> f64>,
305    loop_mode: LoopMode,
306    done: bool,
307    on_complete: Option<Box<dyn FnMut()>>,
308}
309
310impl Keyframes {
311    /// Create a new keyframe animation with total `duration_ticks`.
312    ///
313    /// Uses linear easing by default and [`LoopMode::Once`]. Add stops with
314    /// [`Keyframes::stop`], optionally configure easing, then call
315    /// [`Keyframes::reset`] before sampling.
316    pub fn new(duration_ticks: u64) -> Self {
317        Self {
318            duration_ticks,
319            start_tick: 0,
320            stops: Vec::new(),
321            default_easing: ease_linear,
322            segment_easing: Vec::new(),
323            loop_mode: LoopMode::Once,
324            done: false,
325            on_complete: None,
326        }
327    }
328
329    /// Add a keyframe stop at normalized `position` with `value`.
330    ///
331    /// `position` is clamped to `[0.0, 1.0]`.
332    pub fn stop(mut self, position: f64, value: f64) -> Self {
333        self.stops.push(KeyframeStop {
334            position: clamp01(position),
335            value,
336        });
337        if self.stops.len() >= 2 {
338            self.segment_easing.push(self.default_easing);
339        }
340        self.stops.sort_by(|a, b| a.position.total_cmp(&b.position));
341        self
342    }
343
344    /// Set the default easing used for segments without explicit overrides.
345    ///
346    /// Existing segments are updated to this easing, unless you later override
347    /// them with [`Keyframes::segment_easing`].
348    pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
349        self.default_easing = f;
350        self.segment_easing.fill(f);
351        self
352    }
353
354    /// Override easing for a specific segment index.
355    ///
356    /// Segment `0` is between the first and second stop, segment `1` between
357    /// the second and third, and so on. Out-of-range indices are ignored in
358    /// release builds; debug builds panic via `debug_assert!` to catch
359    /// builder-order mistakes (call `stop()` to add stops before assigning
360    /// per-segment easing).
361    pub fn segment_easing(mut self, segment_index: usize, f: fn(f64) -> f64) -> Self {
362        debug_assert!(
363            segment_index < self.segment_easing.len(),
364            "Keyframes::segment_easing: index {} is out of range \
365             (only {} segments defined; call stop() first to add more stops)",
366            segment_index,
367            self.segment_easing.len(),
368        );
369        if let Some(slot) = self.segment_easing.get_mut(segment_index) {
370            *slot = f;
371        }
372        self
373    }
374
375    /// Set loop behavior used after the first full pass.
376    pub fn loop_mode(mut self, mode: LoopMode) -> Self {
377        self.loop_mode = mode;
378        self
379    }
380
381    /// Register a callback that runs once when the animation completes.
382    pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
383        self.on_complete = Some(Box::new(f));
384        self
385    }
386
387    /// Return the interpolated keyframe value at `tick`.
388    pub fn value(&mut self, tick: u64) -> f64 {
389        if self.stops.is_empty() {
390            self.done = true;
391            if let Some(cb) = &mut self.on_complete {
392                cb();
393            }
394            return 0.0;
395        }
396        if self.stops.len() == 1 {
397            self.done = true;
398            if let Some(cb) = &mut self.on_complete {
399                cb();
400            }
401            return self.stops[0].value;
402        }
403
404        let stops = &self.stops;
405
406        let end_value = stops.last().map_or(0.0, |s| s.value);
407        let loop_tick = match map_loop_tick(
408            tick,
409            self.start_tick,
410            self.duration_ticks,
411            self.loop_mode,
412            &mut self.done,
413        ) {
414            Some(v) => v,
415            None => {
416                if let Some(cb) = &mut self.on_complete {
417                    cb();
418                }
419                return end_value;
420            }
421        };
422
423        if self.duration_ticks == 0 {
424            return stops.last().map_or(0.0, |s| s.value);
425        }
426        let progress = loop_tick as f64 / self.duration_ticks as f64;
427
428        if progress <= stops[0].position {
429            return stops[0].value;
430        }
431        if progress >= 1.0 {
432            return end_value;
433        }
434
435        for i in 0..(stops.len() - 1) {
436            let a = stops[i];
437            let b = stops[i + 1];
438            if progress <= b.position {
439                let span = b.position - a.position;
440                if span <= f64::EPSILON {
441                    return b.value;
442                }
443                let local = clamp01((progress - a.position) / span);
444                let easing = self
445                    .segment_easing
446                    .get(i)
447                    .copied()
448                    .unwrap_or(self.default_easing);
449                let eased = easing(local);
450                return lerp(a.value, b.value, eased);
451            }
452        }
453
454        end_value
455    }
456
457    /// Returns `true` if the animation finished in [`LoopMode::Once`].
458    pub fn is_done(&self) -> bool {
459        self.done
460    }
461
462    /// Restart the keyframe animation from `tick`.
463    pub fn reset(&mut self, tick: u64) {
464        self.start_tick = tick;
465        self.done = false;
466    }
467}
468
469#[derive(Clone, Copy)]
470struct SequenceSegment {
471    from: f64,
472    to: f64,
473    duration_ticks: u64,
474    easing: fn(f64) -> f64,
475}
476
477/// Sequential timeline that chains multiple animation segments.
478///
479/// Use [`Sequence::then`] to append segments. Sampling automatically advances
480/// through each segment as ticks increase.
481///
482/// # Example
483///
484/// ```
485/// use slt::anim::{ease_in_cubic, ease_out_quad, LoopMode, Sequence};
486///
487/// let mut seq = Sequence::new()
488///     .then(0.0, 100.0, 30, ease_out_quad)
489///     .then(100.0, 50.0, 20, ease_in_cubic)
490///     .loop_mode(LoopMode::Repeat);
491///
492/// seq.reset(0);
493/// let _ = seq.value(25);
494/// ```
495pub struct Sequence {
496    segments: Vec<SequenceSegment>,
497    loop_mode: LoopMode,
498    start_tick: u64,
499    done: bool,
500    on_complete: Option<Box<dyn FnMut()>>,
501}
502
503impl Default for Sequence {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509impl Sequence {
510    /// Create an empty sequence.
511    ///
512    /// Defaults to [`LoopMode::Once`]. Add segments with [`Sequence::then`]
513    /// and call [`Sequence::reset`] before sampling.
514    pub fn new() -> Self {
515        Self {
516            segments: Vec::new(),
517            loop_mode: LoopMode::Once,
518            start_tick: 0,
519            done: false,
520            on_complete: None,
521        }
522    }
523
524    /// Append a segment from `from` to `to` over `duration_ticks` ticks.
525    pub fn then(mut self, from: f64, to: f64, duration_ticks: u64, easing: fn(f64) -> f64) -> Self {
526        self.segments.push(SequenceSegment {
527            from,
528            to,
529            duration_ticks,
530            easing,
531        });
532        self
533    }
534
535    /// Set loop behavior used after the first full pass.
536    pub fn loop_mode(mut self, mode: LoopMode) -> Self {
537        self.loop_mode = mode;
538        self
539    }
540
541    /// Register a callback that runs once when the sequence completes.
542    pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
543        self.on_complete = Some(Box::new(f));
544        self
545    }
546
547    /// Return the sequence value at `tick`.
548    pub fn value(&mut self, tick: u64) -> f64 {
549        if self.segments.is_empty() {
550            self.done = true;
551            if let Some(cb) = &mut self.on_complete {
552                cb();
553            }
554            return 0.0;
555        }
556
557        let total_duration = self
558            .segments
559            .iter()
560            .fold(0_u64, |acc, s| acc.saturating_add(s.duration_ticks));
561        let end_value = self.segments.last().map_or(0.0, |s| s.to);
562
563        let loop_tick = match map_loop_tick(
564            tick,
565            self.start_tick,
566            total_duration,
567            self.loop_mode,
568            &mut self.done,
569        ) {
570            Some(v) => v,
571            None => {
572                if let Some(cb) = &mut self.on_complete {
573                    cb();
574                }
575                return end_value;
576            }
577        };
578
579        let mut remaining = loop_tick;
580        for segment in &self.segments {
581            if segment.duration_ticks == 0 {
582                continue;
583            }
584            if remaining < segment.duration_ticks {
585                let progress = remaining as f64 / segment.duration_ticks as f64;
586                let eased = (segment.easing)(clamp01(progress));
587                return lerp(segment.from, segment.to, eased);
588            }
589            remaining -= segment.duration_ticks;
590        }
591
592        end_value
593    }
594
595    /// Returns `true` if the sequence finished in [`LoopMode::Once`].
596    pub fn is_done(&self) -> bool {
597        self.done
598    }
599
600    /// Restart the sequence, treating `tick` as the new start time.
601    pub fn reset(&mut self, tick: u64) {
602        self.start_tick = tick;
603        self.done = false;
604    }
605}
606
607/// Parallel staggered animation where each item starts after a fixed delay.
608///
609/// `Stagger` applies one tween configuration to many items. The start tick for
610/// each item is `start_tick + delay_ticks * item_index`.
611///
612/// By default the animation plays once ([`LoopMode::Once`]). Use
613/// [`Stagger::loop_mode`] to repeat or ping-pong. The total cycle length
614/// includes the delay of every item, so all items finish before the next
615/// cycle begins.
616///
617/// # Example
618///
619/// ```
620/// use slt::anim::{ease_out_quad, Stagger, LoopMode};
621///
622/// let mut stagger = Stagger::new(0.0, 100.0, 30)
623///     .easing(ease_out_quad)
624///     .delay(5)
625///     .loop_mode(LoopMode::Repeat);
626///
627/// stagger.reset(100);
628/// let _ = stagger.value(120, 3);
629/// ```
630pub struct Stagger {
631    from: f64,
632    to: f64,
633    duration_ticks: u64,
634    start_tick: u64,
635    delay_ticks: u64,
636    easing: fn(f64) -> f64,
637    loop_mode: LoopMode,
638    item_count: usize,
639    done: bool,
640    on_complete: Option<Box<dyn FnMut()>>,
641}
642
643impl Stagger {
644    /// Create a new stagger animation template.
645    ///
646    /// Uses linear easing, zero delay, and [`LoopMode::Once`] by default.
647    pub fn new(from: f64, to: f64, duration_ticks: u64) -> Self {
648        Self {
649            from,
650            to,
651            duration_ticks,
652            start_tick: 0,
653            delay_ticks: 0,
654            easing: ease_linear,
655            loop_mode: LoopMode::Once,
656            item_count: 0,
657            done: false,
658            on_complete: None,
659        }
660    }
661
662    /// Set easing for each item's tween.
663    pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
664        self.easing = f;
665        self
666    }
667
668    /// Set delay in ticks between consecutive item starts.
669    pub fn delay(mut self, ticks: u64) -> Self {
670        self.delay_ticks = ticks;
671        self
672    }
673
674    /// Set loop behavior. [`LoopMode::Repeat`] restarts after all items
675    /// finish; [`LoopMode::PingPong`] reverses direction each cycle.
676    pub fn loop_mode(mut self, mode: LoopMode) -> Self {
677        self.loop_mode = mode;
678        self
679    }
680
681    /// Register a callback that runs once when the sampled item completes.
682    pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
683        self.on_complete = Some(Box::new(f));
684        self
685    }
686
687    /// Set the number of items for cycle length calculation.
688    ///
689    /// When using [`LoopMode::Repeat`] or [`LoopMode::PingPong`], the total
690    /// cycle length is `duration_ticks + delay_ticks * (item_count - 1)`.
691    /// If not set, it is inferred from the highest `item_index` seen.
692    pub fn items(mut self, count: usize) -> Self {
693        self.item_count = count;
694        self
695    }
696
697    /// Return the value for `item_index` at `tick`.
698    pub fn value(&mut self, tick: u64, item_index: usize) -> f64 {
699        if item_index >= self.item_count {
700            self.item_count = item_index + 1;
701        }
702
703        let total_cycle = self.total_cycle_ticks();
704
705        let effective_tick = if self.loop_mode == LoopMode::Once {
706            tick
707        } else {
708            let elapsed = tick.wrapping_sub(self.start_tick);
709            let mapped = match self.loop_mode {
710                LoopMode::Repeat => {
711                    if total_cycle == 0 {
712                        0
713                    } else {
714                        elapsed % total_cycle
715                    }
716                }
717                LoopMode::PingPong => {
718                    if total_cycle == 0 {
719                        0
720                    } else {
721                        let full = total_cycle.saturating_mul(2);
722                        let phase = elapsed % full;
723                        if phase < total_cycle {
724                            phase
725                        } else {
726                            full - phase
727                        }
728                    }
729                }
730                LoopMode::Once => unreachable!(),
731            };
732            self.start_tick.wrapping_add(mapped)
733        };
734
735        let delay = self.delay_ticks.wrapping_mul(item_index as u64);
736        let item_start = self.start_tick.wrapping_add(delay);
737
738        if effective_tick < item_start {
739            self.done = false;
740            return self.from;
741        }
742
743        if self.duration_ticks == 0 {
744            self.done = true;
745            if let Some(cb) = &mut self.on_complete {
746                cb();
747            }
748            return self.to;
749        }
750
751        let elapsed = effective_tick - item_start;
752        if elapsed >= self.duration_ticks {
753            self.done = true;
754            if let Some(cb) = &mut self.on_complete {
755                cb();
756            }
757            return self.to;
758        }
759
760        self.done = false;
761        let progress = elapsed as f64 / self.duration_ticks as f64;
762        let eased = (self.easing)(clamp01(progress));
763        lerp(self.from, self.to, eased)
764    }
765
766    fn total_cycle_ticks(&self) -> u64 {
767        let max_delay = self
768            .delay_ticks
769            .wrapping_mul(self.item_count.saturating_sub(1) as u64);
770        self.duration_ticks.saturating_add(max_delay)
771    }
772
773    /// Returns `true` if the **last-sampled** item reached its end value.
774    ///
775    /// `done` is updated on every [`Stagger::value`] call; sampling
776    /// `value(tick, i)` for a non-final `i` after a later item completed can
777    /// reset this flag to `false`. To check whether the entire stagger has
778    /// finished — independent of which item was sampled last — use
779    /// [`Stagger::is_all_done`].
780    pub fn is_done(&self) -> bool {
781        self.done
782    }
783
784    /// Returns `true` if all `item_count` items have passed their end tick.
785    ///
786    /// Independent of which item was sampled last: uses pure tick arithmetic
787    /// against the configured `delay_ticks`, `duration_ticks`, and
788    /// `start_tick`. With [`LoopMode::Repeat`] / [`LoopMode::PingPong`] this
789    /// only reports `true` for the first cycle (loops are re-entered after
790    /// completion).
791    ///
792    /// # Example
793    /// ```
794    /// use slt::Stagger;
795    /// let stagger = Stagger::new(0.0, 100.0, 10).delay(5);
796    /// // 10 items: last item starts at tick 45, ends at tick 55.
797    /// assert!(stagger.is_all_done(60, 10));
798    /// assert!(!stagger.is_all_done(40, 10));
799    /// ```
800    pub fn is_all_done(&self, tick: u64, item_count: usize) -> bool {
801        if item_count == 0 {
802            return true;
803        }
804        let last_start = self.start_tick.saturating_add(
805            self.delay_ticks
806                .saturating_mul(item_count.saturating_sub(1) as u64),
807        );
808        tick >= last_start.saturating_add(self.duration_ticks)
809    }
810
811    /// Restart stagger timing, treating `tick` as the base start time.
812    pub fn reset(&mut self, tick: u64) {
813        self.start_tick = tick;
814        self.done = false;
815    }
816}
817
818fn map_loop_tick(
819    tick: u64,
820    start_tick: u64,
821    duration_ticks: u64,
822    loop_mode: LoopMode,
823    done: &mut bool,
824) -> Option<u64> {
825    if duration_ticks == 0 {
826        *done = true;
827        return None;
828    }
829
830    let elapsed = tick.wrapping_sub(start_tick);
831    match loop_mode {
832        LoopMode::Once => {
833            if elapsed >= duration_ticks {
834                *done = true;
835                None
836            } else {
837                *done = false;
838                Some(elapsed)
839            }
840        }
841        LoopMode::Repeat => {
842            *done = false;
843            Some(elapsed % duration_ticks)
844        }
845        LoopMode::PingPong => {
846            *done = false;
847            let cycle = duration_ticks.saturating_mul(2);
848            if cycle == 0 {
849                return Some(0);
850            }
851            let phase = elapsed % cycle;
852            if phase < duration_ticks {
853                Some(phase)
854            } else {
855                Some(cycle - phase)
856            }
857        }
858    }
859}
860
861/// Spring physics animation that settles toward a target value.
862///
863/// Models a damped harmonic oscillator. Call [`Spring::set_target`] to change
864/// the goal, then call [`Spring::tick`] once per frame to advance the
865/// simulation. Read the current position with [`Spring::value`].
866///
867/// Tune behavior with `stiffness` (how fast it accelerates toward the target)
868/// and `damping` (velocity multiplier per tick). `damping` must be strictly in
869/// `(0.0, 1.0)` — this is **not** the ODE damping ratio ζ. Values `>= 1.0`
870/// conserve or amplify energy, causing perpetual oscillation or divergence.
871///
872/// Recommended range: `0.80..=0.95`.
873/// - `0.95`: slow settle, noticeable oscillation
874/// - `0.85`: balanced (typical UI spring)
875/// - `0.80`: fast settle, minimal oscillation
876///
877/// # Example
878///
879/// ```
880/// use slt::Spring;
881///
882/// let mut spring = Spring::new(0.0, 0.2, 0.85);
883/// spring.set_target(100.0);
884///
885/// for _ in 0..200 {
886///     spring.tick();
887///     if spring.is_settled() { break; }
888/// }
889///
890/// assert!((spring.value() - 100.0).abs() < 0.01);
891/// ```
892pub struct Spring {
893    value: f64,
894    target: f64,
895    velocity: f64,
896    stiffness: f64,
897    damping: f64,
898    settled: bool,
899    on_settle: Option<Box<dyn FnMut()>>,
900}
901
902impl Spring {
903    /// Create a new spring at `initial` position with the given physics parameters.
904    ///
905    /// - `stiffness`: acceleration per unit of displacement (try `0.1`..`0.5`)
906    /// - `damping`: velocity multiplier per tick, `< 1.0` (try `0.8`..`0.95`)
907    pub fn new(initial: f64, stiffness: f64, damping: f64) -> Self {
908        debug_assert!(
909            damping > 0.0 && damping < 1.0,
910            "Spring::new: damping must be in (0, 1), got {damping}. \
911             Values >= 1.0 conserve or amplify energy and never settle."
912        );
913        Self {
914            value: initial,
915            target: initial,
916            velocity: 0.0,
917            stiffness,
918            damping,
919            settled: true,
920            on_settle: None,
921        }
922    }
923
924    /// Register a callback that runs once when the spring settles.
925    pub fn on_settle(mut self, f: impl FnMut() + 'static) -> Self {
926        self.on_settle = Some(Box::new(f));
927        self
928    }
929
930    /// Set the target value the spring will move toward.
931    pub fn set_target(&mut self, target: f64) {
932        self.target = target;
933        self.settled = self.is_settled();
934    }
935
936    /// Advance the spring simulation by one tick.
937    ///
938    /// Call this once per frame before reading [`Spring::value`].
939    pub fn tick(&mut self) {
940        let displacement = self.target - self.value;
941        let spring_force = displacement * self.stiffness;
942        self.velocity = (self.velocity + spring_force) * self.damping;
943        self.value += self.velocity;
944
945        let is_settled = self.is_settled();
946        if !self.settled && is_settled {
947            self.settled = true;
948            if let Some(cb) = &mut self.on_settle {
949                cb();
950            }
951        }
952    }
953
954    /// Return the current spring position.
955    pub fn value(&self) -> f64 {
956        self.value
957    }
958
959    /// Returns `true` if the spring has effectively settled at its target.
960    ///
961    /// Settled means both the distance to target and the velocity are below
962    /// `0.01`.
963    pub fn is_settled(&self) -> bool {
964        (self.target - self.value).abs() < 0.01 && self.velocity.abs() < 0.01
965    }
966}
967
968fn clamp01(t: f64) -> f64 {
969    t.clamp(0.0, 1.0)
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use std::cell::Cell;
976    use std::rc::Rc;
977
978    fn assert_endpoints(f: fn(f64) -> f64) {
979        assert_eq!(f(0.0), 0.0);
980        assert_eq!(f(1.0), 1.0);
981    }
982
983    #[test]
984    fn easing_functions_have_expected_endpoints() {
985        let easing_functions: [fn(f64) -> f64; 9] = [
986            ease_linear,
987            ease_in_quad,
988            ease_out_quad,
989            ease_in_out_quad,
990            ease_in_cubic,
991            ease_out_cubic,
992            ease_in_out_cubic,
993            ease_out_elastic,
994            ease_out_bounce,
995        ];
996
997        for easing in easing_functions {
998            assert_endpoints(easing);
999        }
1000    }
1001
1002    #[test]
1003    fn tween_returns_start_middle_end_values() {
1004        let mut tween = Tween::new(0.0, 10.0, 10);
1005        tween.reset(100);
1006
1007        assert_eq!(tween.value(100), 0.0);
1008        assert_eq!(tween.value(105), 5.0);
1009        assert_eq!(tween.value(110), 10.0);
1010        assert!(tween.is_done());
1011    }
1012
1013    #[test]
1014    fn tween_reset_restarts_animation() {
1015        let mut tween = Tween::new(0.0, 1.0, 10);
1016        tween.reset(0);
1017        let _ = tween.value(10);
1018        assert!(tween.is_done());
1019
1020        tween.reset(20);
1021        assert!(!tween.is_done());
1022        assert_eq!(tween.value(20), 0.0);
1023        assert_eq!(tween.value(30), 1.0);
1024        assert!(tween.is_done());
1025    }
1026
1027    #[test]
1028    fn tween_on_complete_fires_once() {
1029        let count = Rc::new(Cell::new(0));
1030        let callback_count = Rc::clone(&count);
1031        let mut tween = Tween::new(0.0, 10.0, 10).on_complete(move || {
1032            callback_count.set(callback_count.get() + 1);
1033        });
1034
1035        tween.reset(0);
1036        assert_eq!(count.get(), 0);
1037
1038        assert_eq!(tween.value(5), 5.0);
1039        assert_eq!(count.get(), 0);
1040
1041        assert_eq!(tween.value(10), 10.0);
1042        assert_eq!(count.get(), 1);
1043
1044        assert_eq!(tween.value(11), 10.0);
1045        assert_eq!(count.get(), 1);
1046    }
1047
1048    #[test]
1049    fn spring_settles_to_target() {
1050        let mut spring = Spring::new(0.0, 0.2, 0.85);
1051        spring.set_target(10.0);
1052
1053        for _ in 0..300 {
1054            spring.tick();
1055            if spring.is_settled() {
1056                break;
1057            }
1058        }
1059
1060        assert!(spring.is_settled());
1061        assert!((spring.value() - 10.0).abs() < 0.01);
1062    }
1063
1064    #[test]
1065    fn spring_on_settle_fires_once() {
1066        let count = Rc::new(Cell::new(0));
1067        let callback_count = Rc::clone(&count);
1068        let mut spring = Spring::new(0.0, 0.2, 0.85).on_settle(move || {
1069            callback_count.set(callback_count.get() + 1);
1070        });
1071        spring.set_target(10.0);
1072
1073        for _ in 0..500 {
1074            spring.tick();
1075            if spring.is_settled() {
1076                break;
1077            }
1078        }
1079
1080        assert!(spring.is_settled());
1081        assert_eq!(count.get(), 1);
1082
1083        for _ in 0..50 {
1084            spring.tick();
1085        }
1086
1087        assert_eq!(count.get(), 1);
1088    }
1089
1090    #[cfg(debug_assertions)]
1091    #[test]
1092    #[should_panic(expected = "damping must be in (0, 1)")]
1093    fn spring_damping_one_panics_in_debug() {
1094        let _ = Spring::new(0.0, 0.5, 1.0);
1095    }
1096
1097    #[cfg(debug_assertions)]
1098    #[test]
1099    #[should_panic(expected = "damping must be in (0, 1)")]
1100    fn spring_damping_gt_one_panics_in_debug() {
1101        let _ = Spring::new(0.0, 0.5, 2.0);
1102    }
1103
1104    #[test]
1105    fn spring_valid_damping_settles() {
1106        for &d in &[0.5_f64, 0.7, 0.85, 0.95] {
1107            let mut s = Spring::new(0.0, 0.2, d);
1108            s.set_target(100.0);
1109            for _ in 0..1000 {
1110                s.tick();
1111                if s.is_settled() {
1112                    break;
1113                }
1114            }
1115            assert!(s.is_settled(), "damping={d} should settle");
1116            assert!((s.value() - 100.0).abs() < 0.01, "damping={d} value off");
1117        }
1118    }
1119
1120    #[test]
1121    fn lerp_interpolates_values() {
1122        assert_eq!(lerp(0.0, 10.0, 0.0), 0.0);
1123        assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
1124        assert_eq!(lerp(0.0, 10.0, 1.0), 10.0);
1125    }
1126
1127    #[test]
1128    fn keyframes_interpolates_across_multiple_stops() {
1129        let mut keyframes = Keyframes::new(100)
1130            .stop(0.0, 0.0)
1131            .stop(0.3, 100.0)
1132            .stop(0.7, 50.0)
1133            .stop(1.0, 80.0)
1134            .easing(ease_linear);
1135
1136        keyframes.reset(0);
1137        assert_eq!(keyframes.value(0), 0.0);
1138        assert_eq!(keyframes.value(15), 50.0);
1139        assert_eq!(keyframes.value(30), 100.0);
1140        assert_eq!(keyframes.value(50), 75.0);
1141        assert_eq!(keyframes.value(70), 50.0);
1142        assert_eq!(keyframes.value(85), 65.0);
1143        assert_eq!(keyframes.value(100), 80.0);
1144        assert!(keyframes.is_done());
1145    }
1146
1147    #[test]
1148    fn keyframes_repeat_loop_restarts() {
1149        let mut keyframes = Keyframes::new(10)
1150            .stop(0.0, 0.0)
1151            .stop(1.0, 10.0)
1152            .loop_mode(LoopMode::Repeat);
1153
1154        keyframes.reset(0);
1155        assert_eq!(keyframes.value(5), 5.0);
1156        assert_eq!(keyframes.value(10), 0.0);
1157        assert_eq!(keyframes.value(12), 2.0);
1158        assert!(!keyframes.is_done());
1159    }
1160
1161    #[test]
1162    fn keyframes_pingpong_reverses_direction() {
1163        let mut keyframes = Keyframes::new(10)
1164            .stop(0.0, 0.0)
1165            .stop(1.0, 10.0)
1166            .loop_mode(LoopMode::PingPong);
1167
1168        keyframes.reset(0);
1169        assert_eq!(keyframes.value(8), 8.0);
1170        assert_eq!(keyframes.value(10), 10.0);
1171        assert_eq!(keyframes.value(12), 8.0);
1172        assert_eq!(keyframes.value(15), 5.0);
1173        assert!(!keyframes.is_done());
1174    }
1175
1176    #[test]
1177    fn sequence_chains_segments_in_order() {
1178        let mut sequence = Sequence::new()
1179            .then(0.0, 100.0, 30, ease_linear)
1180            .then(100.0, 50.0, 20, ease_linear)
1181            .then(50.0, 200.0, 40, ease_linear);
1182
1183        sequence.reset(0);
1184        assert_eq!(sequence.value(15), 50.0);
1185        assert_eq!(sequence.value(30), 100.0);
1186        assert_eq!(sequence.value(40), 75.0);
1187        assert_eq!(sequence.value(50), 50.0);
1188        assert_eq!(sequence.value(70), 125.0);
1189        assert_eq!(sequence.value(90), 200.0);
1190        assert!(sequence.is_done());
1191    }
1192
1193    #[test]
1194    fn sequence_loop_modes_repeat_and_pingpong_work() {
1195        let mut repeat = Sequence::new()
1196            .then(0.0, 10.0, 10, ease_linear)
1197            .loop_mode(LoopMode::Repeat);
1198        repeat.reset(0);
1199        assert_eq!(repeat.value(12), 2.0);
1200        assert!(!repeat.is_done());
1201
1202        let mut pingpong = Sequence::new()
1203            .then(0.0, 10.0, 10, ease_linear)
1204            .loop_mode(LoopMode::PingPong);
1205        pingpong.reset(0);
1206        assert_eq!(pingpong.value(12), 8.0);
1207        assert!(!pingpong.is_done());
1208    }
1209
1210    #[test]
1211    fn stagger_applies_per_item_delay() {
1212        let mut stagger = Stagger::new(0.0, 100.0, 20).easing(ease_linear).delay(5);
1213
1214        stagger.reset(0);
1215        assert_eq!(stagger.value(4, 3), 0.0);
1216        assert_eq!(stagger.value(15, 3), 0.0);
1217        assert_eq!(stagger.value(20, 3), 25.0);
1218        assert_eq!(stagger.value(35, 3), 100.0);
1219        assert!(stagger.is_done());
1220    }
1221
1222    /// Regression test for issue #127:
1223    /// `is_all_done` reports completion across all items, independent of last sample.
1224    #[test]
1225    fn stagger_is_all_done_returns_false_mid_animation() {
1226        let stagger = Stagger::new(0.0, 100.0, 10).delay(5);
1227        // 5 items: item 0 ends at tick 10, item 4 ends at tick 30.
1228        assert!(!stagger.is_all_done(15, 5), "items still in progress");
1229    }
1230
1231    /// Regression test for issue #127: `is_all_done` returns true after last item.
1232    #[test]
1233    fn stagger_is_all_done_returns_true_after_last_item() {
1234        let stagger = Stagger::new(0.0, 100.0, 10).delay(5);
1235        assert!(stagger.is_all_done(31, 5), "all items done by tick 31");
1236    }
1237
1238    /// Regression test for issue #127: `is_done` reflects last sampled item only.
1239    #[test]
1240    fn stagger_is_done_reflects_last_sampled_item_only() {
1241        let mut stagger = Stagger::new(0.0, 100.0, 10).delay(5);
1242        stagger.value(100, 4); // item 4 already past end → done = true
1243        assert!(stagger.is_done());
1244        // Sample item 2 mid-flight → done resets.
1245        stagger.value(15, 2);
1246        assert!(!stagger.is_done(), "is_done reflects last sampled item");
1247    }
1248
1249    /// Regression test for issue #127: empty stagger trivially "all done".
1250    #[test]
1251    fn stagger_is_all_done_zero_items() {
1252        let stagger = Stagger::new(0.0, 100.0, 10);
1253        assert!(stagger.is_all_done(0, 0));
1254    }
1255
1256    /// Regression test for issue #130: out-of-range segment_easing panics in debug builds.
1257    #[cfg(debug_assertions)]
1258    #[test]
1259    #[should_panic(expected = "out of range")]
1260    fn keyframes_segment_easing_oob_panics_in_debug() {
1261        // 2 stops → 1 segment (only index 0 valid).
1262        let _ = Keyframes::new(60)
1263            .stop(0.0, 0.0)
1264            .stop(1.0, 100.0)
1265            .segment_easing(5, ease_linear);
1266    }
1267
1268    /// Regression test for issue #130: valid segment_easing index does not panic.
1269    #[test]
1270    fn keyframes_segment_easing_valid_index() {
1271        let kf = Keyframes::new(60)
1272            .stop(0.0, 0.0)
1273            .stop(0.5, 50.0)
1274            .stop(1.0, 100.0)
1275            .segment_easing(0, ease_in_quad)
1276            .segment_easing(1, ease_out_quad);
1277        let _ = kf;
1278    }
1279}