Skip to main content

cranpose_liquid/
motion.rs

1//! Liquid motion: the spring presets every component shares, and the press
2//! interaction (scale + specular boost) that makes glass feel physical.
3
4use std::{
5    cell::{Cell, RefCell},
6    rc::Rc,
7};
8
9use cranpose_animation::{Animatable, AnimationType, Easing, spring, tween};
10use cranpose_core::{RuntimeHandle, State, with_current_composer};
11use cranpose_foundation::VelocityTracker1D;
12use cranpose_macros::composable;
13use cranpose_ui::{Modifier, MutableInteractionSource};
14use cranpose_ui_graphics::GraphicsLayer;
15
16use crate::dynamics::{LiquidDynamics, LiquidPose};
17
18const FLUID_RELAX_MS: u64 = 420;
19const VISUAL_HANDOFF_TOLERANCE_IN_ITEMS: f32 = 0.12;
20
21pub(crate) fn liquid_visual_index(
22    selected: usize,
23    lens_position: f32,
24    item_width: f32,
25    count: usize,
26    lens_owns_selection: bool,
27) -> usize {
28    if count == 0 {
29        return 0;
30    }
31    let selected = selected.min(count - 1);
32    if !lens_owns_selection || !lens_position.is_finite() || item_width <= f32::EPSILON {
33        return selected;
34    }
35    ((lens_position + item_width * 0.5) / item_width)
36        .floor()
37        .clamp(0.0, count.saturating_sub(1) as f32) as usize
38}
39
40pub(crate) fn liquid_axis_owns_visual_selection(
41    direct: bool,
42    lens_position: f32,
43    state_position: f32,
44    item_width: f32,
45) -> bool {
46    direct
47        || (lens_position - state_position).abs()
48            > item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
49}
50
51/// Named springs used across the Liquid components (value-space, velocity
52/// preserving).
53pub struct LiquidMotion;
54
55impl LiquidMotion {
56    /// Snappy interactions: presses, toggles, selection moves.
57    pub fn snappy() -> AnimationType {
58        spring(0.85, 900.0)
59    }
60
61    /// The droplet feel: visible overshoot for morphing shapes.
62    pub fn bouncy() -> AnimationType {
63        spring(0.55, 500.0)
64    }
65
66    /// Gentle settle for large surfaces (sheets, menus).
67    pub fn smooth() -> AnimationType {
68        spring(1.0, 400.0)
69    }
70
71    /// The leading edge of a stretching selection blob (runs ahead).
72    pub fn blob_leading() -> AnimationType {
73        spring(0.8, 900.0)
74    }
75
76    /// The trailing edge of a stretching selection blob (drags behind, giving
77    /// the droplet elongation while in motion).
78    pub fn blob_trailing() -> AnimationType {
79        spring(0.9, 380.0)
80    }
81
82    /// A released lens flying to its committed slot: the reference
83    /// bottom-bar transfer arrives in ~170 ms (on-white-click sheet,
84    /// f_0040 departure to f_0050 arrival at 60 fps) with no visible
85    /// overshoot; the optical settle continues after the geometry lands.
86    pub fn glide() -> AnimationType {
87        spring(1.0, 500.0)
88    }
89}
90
91pub(crate) struct LiquidDragAxis {
92    animation: RefCell<Animatable<f32>>,
93    follow: Option<AnimationType>,
94    pointer: Cell<Option<f32>>,
95    velocity: RefCell<VelocityTracker1D>,
96    runtime: RuntimeHandle,
97    last_sample_ms: Cell<Option<i64>>,
98    dynamics: LiquidDynamics,
99    fluid_clock: RefCell<Animatable<f32>>,
100}
101
102impl LiquidDragAxis {
103    fn new(initial: f32, runtime: RuntimeHandle, follow: Option<AnimationType>) -> Self {
104        Self {
105            follow,
106            animation: RefCell::new(Animatable::new(initial, runtime.clone())),
107            pointer: Cell::new(None),
108            velocity: RefCell::new(VelocityTracker1D::new()),
109            dynamics: LiquidDynamics::new(runtime.clone()),
110            fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
111            runtime,
112            last_sample_ms: Cell::new(None),
113        }
114    }
115
116    fn arm_fluid_frames(&self) {
117        let mut clock = self.fluid_clock.borrow_mut();
118        clock.snapTo(0.0);
119        clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
120    }
121
122    fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
123        let candidate = event_time_ms
124            .or_else(|| {
125                self.runtime
126                    .last_frame_time_nanos()
127                    .map(|nanos| (nanos / 1_000_000) as i64)
128            })
129            .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
130        let monotonic = self
131            .last_sample_ms
132            .get()
133            .map_or(candidate, |last| candidate.max(last + 1));
134        self.last_sample_ms.set(Some(monotonic));
135        monotonic
136    }
137
138    pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
139        let time_ms = self.sample_time_ms(event_time_ms);
140        let mut velocity = self.velocity.borrow_mut();
141        velocity.reset();
142        velocity.add_data_point(time_ms, position);
143        self.pointer.set(Some(position));
144        self.animation.borrow_mut().snapTo(position);
145        self.dynamics.anchor_pointer((position, 0.0));
146        self.arm_fluid_frames();
147    }
148
149    pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
150        self.move_to_at(
151            position,
152            event_time_ms,
153            self.runtime.last_frame_time_nanos(),
154        );
155    }
156
157    fn move_to_at(
158        &self,
159        position: f32,
160        event_time_ms: Option<i64>,
161        animation_time_nanos: Option<u64>,
162    ) {
163        if self.pointer.get().is_none() {
164            return;
165        }
166        let previous_time_ms = self.last_sample_ms.get();
167        let time_ms = self.sample_time_ms(event_time_ms);
168        self.velocity.borrow_mut().add_data_point(time_ms, position);
169        self.pointer.set(Some(position));
170        if let Some(animation) = self.follow {
171            let mut value = self.animation.borrow_mut();
172            if let Some(time) = animation_time_nanos {
173                value.animate_to_at(position, animation, time);
174            } else {
175                value.animateTo(position, animation);
176            }
177        } else {
178            self.animation.borrow_mut().snapTo(position);
179        }
180        if let Some(previous_time_ms) = previous_time_ms.filter(|_| self.follow.is_none()) {
181            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
182            self.dynamics.advance_pointer((position, 0.0), dt);
183        }
184        self.arm_fluid_frames();
185    }
186
187    pub(crate) fn release_to(
188        &self,
189        target: f32,
190        event_time_ms: Option<i64>,
191        animation: AnimationType,
192    ) {
193        self.release_to_at(
194            target,
195            event_time_ms,
196            animation,
197            self.runtime.last_frame_time_nanos(),
198        );
199    }
200
201    fn release_to_at(
202        &self,
203        target: f32,
204        event_time_ms: Option<i64>,
205        animation: AnimationType,
206        animation_time_nanos: Option<u64>,
207    ) {
208        let Some(position) = self.pointer.take() else {
209            self.settle_to_at(target, animation, animation_time_nanos);
210            return;
211        };
212        let time_ms = self.sample_time_ms(event_time_ms);
213        self.velocity.borrow_mut().add_data_point(time_ms, position);
214        let release_velocity = if self.follow.is_some() {
215            self.animation.borrow().velocity()
216        } else {
217            self.velocity.borrow().calculate_velocity_with_max(8_000.0)
218        };
219        self.dynamics.release_pointer();
220        let mut value = self.animation.borrow_mut();
221        if let Some(time) = animation_time_nanos {
222            if self.follow.is_some() {
223                value.animate_to_at(target, animation, time);
224            } else {
225                value.animate_to_with_velocity_at(target, release_velocity, animation, time);
226            }
227        } else {
228            value.animate_to_with_velocity(target, release_velocity, animation);
229        }
230    }
231
232    pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
233        let Some(_) = self.pointer.get() else {
234            self.animation.borrow_mut().snapTo(position);
235            return;
236        };
237        let previous_time_ms = self.last_sample_ms.get();
238        let time_ms = self.sample_time_ms(event_time_ms);
239        self.velocity.borrow_mut().add_data_point(time_ms, position);
240        if let Some(previous_time_ms) = previous_time_ms {
241            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
242            self.dynamics.advance_pointer((position, 0.0), dt);
243        }
244        self.pointer.set(None);
245        self.animation.borrow_mut().snapTo(position);
246        self.dynamics.release_pointer();
247        self.arm_fluid_frames();
248    }
249
250    pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
251        self.settle_to_at(target, animation, self.runtime.last_frame_time_nanos());
252    }
253
254    fn settle_to_at(&self, target: f32, animation: AnimationType, time: Option<u64>) {
255        if self.pointer.get().is_some() {
256            return;
257        }
258        let mut value = self.animation.borrow_mut();
259        if (value.target() - target).abs() > f32::EPSILON {
260            if let Some(time) = time {
261                value.animate_to_at(target, animation, time);
262            } else {
263                value.animateTo(target, animation);
264            }
265        }
266    }
267
268    pub(crate) fn value(&self) -> f32 {
269        let _ = self.fluid_clock.borrow().state().value();
270        if self.follow.is_some() {
271            self.animation.borrow().state().value()
272        } else {
273            self.pointer
274                .get()
275                .unwrap_or_else(|| self.animation.borrow().state().value())
276        }
277    }
278
279    pub(crate) fn liquid_pose(&self) -> LiquidPose {
280        self.dynamics.update_pointer((self.value(), 0.0))
281    }
282
283    pub(crate) fn is_dragging(&self) -> bool {
284        self.pointer.get().is_some()
285    }
286}
287
288#[composable]
289pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
290    remember_liquid_axis(initial, None)
291}
292
293#[composable]
294pub(crate) fn remember_liquid_follow_axis(
295    initial: f32,
296    animation: AnimationType,
297) -> Rc<LiquidDragAxis> {
298    remember_liquid_axis(initial, Some(animation))
299}
300
301#[composable]
302fn remember_liquid_axis(initial: f32, follow: Option<AnimationType>) -> Rc<LiquidDragAxis> {
303    with_current_composer(|composer| {
304        let runtime = composer.runtime_handle();
305        composer
306            .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime, follow)))
307            .with(Rc::clone)
308    })
309}
310
311pub(crate) struct LiquidLensGesture {
312    pub axis: Rc<LiquidDragAxis>,
313    pub cell_width: f32,
314    pub cell_offset: f32,
315    pub count: usize,
316    pub tap_slop: f32,
317    pub drag_left: Rc<dyn Fn(f32) -> f32>,
318    pub rest_left: Rc<dyn Fn(usize) -> f32>,
319    pub selected: usize,
320    pub on_pressed: Rc<dyn Fn(bool, Option<u64>)>,
321    pub on_touch: Rc<dyn Fn(f32, f32)>,
322    pub on_select: Rc<dyn Fn(usize)>,
323}
324
325impl LiquidLensGesture {
326    fn commit_index(&self, position: f32) -> usize {
327        (((position - self.cell_offset) / self.cell_width.max(1.0)).floor() as isize)
328            .clamp(0, self.count.saturating_sub(1) as isize) as usize
329    }
330}
331
332pub(crate) async fn liquid_lens_gesture(
333    scope: cranpose_ui::PointerInputScope,
334    gesture: LiquidLensGesture,
335) {
336    use cranpose_foundation::{PointerEventKind, PointerId};
337    use cranpose_services::{HapticFeedback, default_haptics};
338
339    scope
340        .await_pointer_event_scope(|await_scope| async move {
341            let mut down_x = 0.0f32;
342            let mut moved = false;
343            let mut active_pointer = Option::<PointerId>::None;
344            loop {
345                let event = await_scope.await_pointer_event().await;
346                match event.kind {
347                    PointerEventKind::Down if active_pointer.is_none() => {
348                        active_pointer = Some(event.id);
349                        down_x = event.position.x;
350                        moved = false;
351                        gesture.axis.settle_to_at(
352                            (gesture.rest_left)(gesture.commit_index(down_x)),
353                            LiquidMotion::glide(),
354                            event.animation_time_nanos,
355                        );
356                        (gesture.on_pressed)(true, event.animation_time_nanos);
357                        (gesture.on_touch)(event.position.x, event.position.y);
358                        default_haptics().perform(HapticFeedback::Selection);
359                        event.consume();
360                    }
361                    PointerEventKind::Move if active_pointer == Some(event.id) => {
362                        moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
363                        if moved {
364                            let target = (gesture.drag_left)(event.position.x);
365                            if gesture.axis.is_dragging() {
366                                gesture.axis.move_to_at(
367                                    target,
368                                    event.time_ms,
369                                    event.animation_time_nanos,
370                                );
371                            } else if (gesture.axis.value() - target).abs()
372                                <= gesture.cell_width * 0.6
373                            {
374                                gesture.axis.begin(gesture.axis.value(), event.time_ms);
375                                gesture.axis.move_to_at(
376                                    target,
377                                    event.time_ms,
378                                    event.animation_time_nanos,
379                                );
380                            } else {
381                                gesture.axis.settle_to_at(
382                                    target,
383                                    LiquidMotion::glide(),
384                                    event.animation_time_nanos,
385                                );
386                            }
387                        }
388                        (gesture.on_touch)(event.position.x, event.position.y);
389                        event.consume();
390                    }
391                    PointerEventKind::Up if active_pointer == Some(event.id) => {
392                        active_pointer = None;
393                        (gesture.on_pressed)(false, event.animation_time_nanos);
394                        let commit_x = if moved { event.position.x } else { down_x };
395                        let index = gesture.commit_index(commit_x);
396                        gesture.axis.release_to_at(
397                            (gesture.rest_left)(index),
398                            event.time_ms,
399                            LiquidMotion::glide(),
400                            event.animation_time_nanos,
401                        );
402                        default_haptics().perform(HapticFeedback::ImpactLight);
403                        (gesture.on_select)(index);
404                        event.consume();
405                    }
406                    PointerEventKind::Cancel if active_pointer == Some(event.id) => {
407                        active_pointer = None;
408                        (gesture.on_pressed)(false, event.animation_time_nanos);
409                        gesture.axis.release_to_at(
410                            (gesture.rest_left)(gesture.selected),
411                            event.time_ms,
412                            LiquidMotion::glide(),
413                            event.animation_time_nanos,
414                        );
415                        event.consume();
416                    }
417                    _ => {}
418                }
419            }
420        })
421        .await;
422}
423
424/// Press feedback for glass controls, per the Liquid Glass law: touched glass
425/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
426/// TRANSPARENT (the returned content alpha dips while pressed, the reference
427/// "…" dots fading as the button lifts). Returns the pressed state so callers
428/// can also boost the specular highlight.
429///
430/// Apply the returned modifier *outside* the glass effect so the whole lens
431/// scales together; apply the content alpha to the label/icon layer.
432#[composable]
433pub fn liquid_press_scale(
434    modifier: Modifier,
435    interaction_source: MutableInteractionSource,
436    pressed_scale: f32,
437) -> (Modifier, State<bool>, State<f32>) {
438    let pressed = interaction_source.collectIsPressedAsState();
439    let scale = cranpose_animation::animateFloatAsState(
440        if pressed.get() {
441            pressed_scale.max(1.0)
442        } else {
443            1.0
444        },
445        LiquidMotion::snappy(),
446        "liquid-press-scale",
447    );
448    let content_alpha = cranpose_animation::animateFloatAsState(
449        if pressed.get() { 0.35 } else { 1.0 },
450        LiquidMotion::smooth(),
451        "liquid-press-content",
452    );
453    let modifier = modifier.graphics_layer(move || {
454        let scale = scale.get();
455        GraphicsLayer {
456            scale_x: scale,
457            scale_y: scale,
458            ..Default::default()
459        }
460    });
461    (modifier, pressed, content_alpha)
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
469        let runtime =
470            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
471        let axis = LiquidDragAxis::new(initial, runtime.handle(), None);
472        (runtime, axis)
473    }
474
475    fn native_trace_error(follow: Option<AnimationType>) -> f32 {
476        let runtime =
477            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
478        let mut axis = LiquidDragAxis::new(0.0, runtime.handle(), follow);
479        let mut origin = 1_000_000_000u64;
480        let mut squared_error = 0.0;
481        let mut count = 0;
482        for line in include_str!("../tests/fixtures/native_tab_drag.csv").lines() {
483            let fields: Vec<_> = line.split(',').collect();
484            let time: f64 = fields[1].parse().expect("trace time");
485            let position: f32 = fields[2].parse().expect("trace position");
486            if fields[0] == "start" {
487                origin += 10_000_000_000;
488                axis = LiquidDragAxis::new(position, runtime.handle(), follow);
489                runtime.handle().drain_frame_callbacks(origin);
490                axis.begin(position, Some((origin / 1_000_000) as i64));
491                continue;
492            }
493            let nanos = origin + (time * 1e9) as u64;
494            runtime.handle().drain_frame_callbacks(nanos);
495            if fields[0] == "input" {
496                axis.move_to(position, Some((nanos / 1_000_000) as i64));
497            } else {
498                squared_error += (axis.value() - position).powi(2);
499                count += 1;
500            }
501        }
502        assert!(count > 200);
503        (squared_error / count as f32).sqrt()
504    }
505
506    #[test]
507    fn tab_follow_matches_both_native_drag_velocities_and_held_settling() {
508        let following = native_trace_error(Some(spring(0.85, 650.0)));
509        let direct = native_trace_error(None);
510        assert!(following < 3.0, "native trace RMS: {following}");
511        assert!(direct > 15.0, "direct-input counterexample RMS: {direct}");
512    }
513
514    #[test]
515    fn releasing_a_following_lens_preserves_its_rendered_velocity() {
516        let runtime =
517            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
518        let axis = LiquidDragAxis::new(0.0, runtime.handle(), Some(spring(0.85, 650.0)));
519        runtime.handle().drain_frame_callbacks(1_000_000_000);
520        axis.begin(0.0, Some(1000));
521        axis.move_to(100.0, Some(1016));
522        runtime.handle().drain_frame_callbacks(1_016_000_000);
523        runtime.handle().drain_frame_callbacks(1_032_000_000);
524        let before = axis.animation.borrow().velocity();
525        assert!(before > 0.0);
526        axis.release_to(100.0, Some(1200), LiquidMotion::glide());
527        assert_eq!(axis.animation.borrow().velocity(), before);
528        assert!(!axis.is_dragging());
529    }
530
531    #[test]
532    fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
533        let (_runtime, axis) = axis(10.0);
534        axis.begin(20.0, Some(0));
535        assert_eq!(axis.value(), 20.0);
536        axis.move_to(180.0, Some(16));
537        assert_eq!(axis.value(), 180.0);
538    }
539
540    #[test]
541    fn direct_manipulation_owns_visual_selection_until_the_lens_reaches_state() {
542        assert_eq!(liquid_visual_index(2, 0.0, 78.0, 4, false), 2);
543        assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
544        assert_eq!(liquid_visual_index(0, 1.6 * 78.0, 78.0, 4, true), 2);
545        assert_eq!(liquid_visual_index(0, 1.4 * 78.0, 78.0, 4, true), 1);
546        assert_eq!(liquid_visual_index(0, 2.71 * 78.0, 78.0, 4, true), 3);
547        assert_eq!(liquid_visual_index(0, 3.0 * 78.0, 78.0, 4, true), 3);
548        assert_eq!(liquid_visual_index(0, 99.0 * 78.0, 78.0, 4, true), 3);
549        assert_eq!(liquid_visual_index(9, f32::NAN, 78.0, 4, true), 3);
550        assert_eq!(liquid_visual_index(0, 78.0, 0.0, 4, true), 0);
551
552        assert!(liquid_axis_owns_visual_selection(true, 0.0, 0.0, 78.0));
553        assert!(liquid_axis_owns_visual_selection(false, 78.0, 0.0, 78.0));
554        assert!(!liquid_axis_owns_visual_selection(
555            false,
556            78.0 * 0.05,
557            0.0,
558            78.0,
559        ));
560    }
561
562    #[test]
563    fn pointer_sample_excites_the_incompressible_pose_before_render() {
564        let (_runtime, axis) = axis(0.0);
565        axis.begin(0.0, Some(0));
566        axis.move_to(14.0, Some(16));
567        let pose = axis.liquid_pose();
568        let deformation = (pose.stretch - 1.0).abs();
569        assert!(
570            (0.08..=0.12).contains(&deformation),
571            "the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
572        );
573        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
574        assert_eq!(axis.value(), 14.0);
575    }
576
577    #[test]
578    fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
579        let (_runtime, axis) = axis(0.0);
580        axis.runtime.drain_frame_callbacks(1_000_000);
581        axis.begin(0.0, Some(0));
582        axis.move_to(14.0, Some(16));
583        let sampled = axis.liquid_pose();
584
585        axis.runtime.drain_frame_callbacks(17_000_000);
586        let next_frame = axis.liquid_pose();
587
588        assert!(
589            (next_frame.stretch - sampled.stretch).abs() < 0.08,
590            "a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
591        );
592        assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
593    }
594
595    #[test]
596    fn controlled_retargets_wait_until_direct_manipulation_ends() {
597        let (_runtime, axis) = axis(10.0);
598        axis.begin(40.0, Some(0));
599        axis.settle_to(90.0, LiquidMotion::snappy());
600        assert_eq!(axis.value(), 40.0);
601        axis.release_to(90.0, Some(16), LiquidMotion::snappy());
602        assert!(!axis.is_dragging());
603        assert_eq!(axis.animation.borrow().target(), 90.0);
604    }
605
606    #[test]
607    fn continuous_release_stops_translation_without_erasing_fluid_velocity() {
608        let (_runtime, axis) = axis(0.0);
609        axis.runtime.drain_frame_callbacks(1_000_000);
610        axis.begin(0.0, Some(0));
611        axis.move_to(80.0, Some(16));
612        let moving = axis.liquid_pose();
613
614        axis.finish_at(80.0, Some(17));
615        assert!(!axis.is_dragging());
616        assert_eq!(axis.value(), 80.0);
617        assert!(moving.speed > 0.0);
618
619        let mut relaxed = moving;
620        for frame in 2..=12 {
621            axis.runtime.drain_frame_callbacks(frame * 17_000_000);
622            assert_eq!(axis.value(), 80.0, "frame {frame} backtracked");
623            relaxed = axis.liquid_pose();
624        }
625        assert!(
626            relaxed.speed < moving.speed,
627            "shape velocity must relax even though translation stops"
628        );
629    }
630
631    #[test]
632    fn released_flight_is_critically_damped() {
633        let AnimationType::Spring(spec) = LiquidMotion::glide() else {
634            panic!("released flight must use a spring");
635        };
636        assert_eq!(spec.damping_ratio, 1.0);
637        assert_eq!(spec.stiffness, 500.0);
638    }
639
640    fn spring_of(animation: AnimationType) -> cranpose_animation::SpringSpec {
641        match animation {
642            AnimationType::Spring(spec) => spec,
643            other => panic!("expected a spring, got {other:?}"),
644        }
645    }
646
647    #[test]
648    fn a_stretching_blob_runs_its_leading_edge_ahead_of_its_trailing_edge() {
649        let leading = spring_of(LiquidMotion::blob_leading());
650        let trailing = spring_of(LiquidMotion::blob_trailing());
651        assert!(
652            leading.stiffness > trailing.stiffness,
653            "the edge that runs ahead has to be the stiffer one: \
654             leading {} vs trailing {}",
655            leading.stiffness,
656            trailing.stiffness
657        );
658        assert!(
659            leading.damping_ratio < 1.0 && trailing.damping_ratio < 1.0,
660            "both edges stay under-damped so the droplet keeps its elongation"
661        );
662    }
663
664    #[test]
665    fn every_named_motion_is_a_settled_spring() {
666        for (name, animation) in [
667            ("snappy", LiquidMotion::snappy()),
668            ("bouncy", LiquidMotion::bouncy()),
669            ("smooth", LiquidMotion::smooth()),
670            ("blob_leading", LiquidMotion::blob_leading()),
671            ("blob_trailing", LiquidMotion::blob_trailing()),
672            ("glide", LiquidMotion::glide()),
673        ] {
674            let spec = spring_of(animation);
675            assert!(spec.stiffness > 0.0, "{name} needs a stiffness");
676            assert!(spec.damping_ratio > 0.0, "{name} needs damping");
677            assert_eq!(spec.delay_millis, 0, "{name} starts on the frame it is set");
678        }
679    }
680}