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    pointer: Cell<Option<f32>>,
94    velocity: RefCell<VelocityTracker1D>,
95    runtime: RuntimeHandle,
96    last_sample_ms: Cell<Option<i64>>,
97    dynamics: LiquidDynamics,
98    fluid_clock: RefCell<Animatable<f32>>,
99}
100
101impl LiquidDragAxis {
102    fn new(initial: f32, runtime: RuntimeHandle) -> Self {
103        Self {
104            animation: RefCell::new(Animatable::new(initial, runtime.clone())),
105            pointer: Cell::new(None),
106            velocity: RefCell::new(VelocityTracker1D::new()),
107            dynamics: LiquidDynamics::new(runtime.clone()),
108            fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
109            runtime,
110            last_sample_ms: Cell::new(None),
111        }
112    }
113
114    fn arm_fluid_frames(&self) {
115        let mut clock = self.fluid_clock.borrow_mut();
116        clock.snapTo(0.0);
117        clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
118    }
119
120    fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
121        let candidate = event_time_ms
122            .or_else(|| {
123                self.runtime
124                    .last_frame_time_nanos()
125                    .map(|nanos| (nanos / 1_000_000) as i64)
126            })
127            .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
128        let monotonic = self
129            .last_sample_ms
130            .get()
131            .map_or(candidate, |last| candidate.max(last + 1));
132        self.last_sample_ms.set(Some(monotonic));
133        monotonic
134    }
135
136    pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
137        let time_ms = self.sample_time_ms(event_time_ms);
138        let mut velocity = self.velocity.borrow_mut();
139        velocity.reset();
140        velocity.add_data_point(time_ms, position);
141        self.pointer.set(Some(position));
142        self.animation.borrow_mut().snapTo(position);
143        self.dynamics.anchor_pointer((position, 0.0));
144        self.arm_fluid_frames();
145    }
146
147    pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
148        if self.pointer.get().is_none() {
149            return;
150        }
151        let previous_time_ms = self.last_sample_ms.get();
152        let time_ms = self.sample_time_ms(event_time_ms);
153        self.velocity.borrow_mut().add_data_point(time_ms, position);
154        self.pointer.set(Some(position));
155        self.animation.borrow_mut().snapTo(position);
156        if let Some(previous_time_ms) = previous_time_ms {
157            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
158            self.dynamics.advance_pointer((position, 0.0), dt);
159        }
160        self.arm_fluid_frames();
161    }
162
163    pub(crate) fn release_to(
164        &self,
165        target: f32,
166        event_time_ms: Option<i64>,
167        animation: AnimationType,
168    ) {
169        let Some(position) = self.pointer.take() else {
170            self.settle_to(target, animation);
171            return;
172        };
173        let time_ms = self.sample_time_ms(event_time_ms);
174        self.velocity.borrow_mut().add_data_point(time_ms, position);
175        let release_velocity = self.velocity.borrow().calculate_velocity_with_max(8_000.0);
176        self.dynamics.release_pointer();
177        self.animation
178            .borrow_mut()
179            .animate_to_with_velocity(target, release_velocity, animation);
180    }
181
182    pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
183        let Some(_) = self.pointer.get() else {
184            self.animation.borrow_mut().snapTo(position);
185            return;
186        };
187        let previous_time_ms = self.last_sample_ms.get();
188        let time_ms = self.sample_time_ms(event_time_ms);
189        self.velocity.borrow_mut().add_data_point(time_ms, position);
190        if let Some(previous_time_ms) = previous_time_ms {
191            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
192            self.dynamics.advance_pointer((position, 0.0), dt);
193        }
194        self.pointer.set(None);
195        self.animation.borrow_mut().snapTo(position);
196        self.dynamics.release_pointer();
197        self.arm_fluid_frames();
198    }
199
200    pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
201        if self.pointer.get().is_some() {
202            return;
203        }
204        let mut value = self.animation.borrow_mut();
205        if (value.target() - target).abs() > f32::EPSILON {
206            value.animateTo(target, animation);
207        }
208    }
209
210    pub(crate) fn value(&self) -> f32 {
211        let _ = self.fluid_clock.borrow().state().value();
212        self.pointer
213            .get()
214            .unwrap_or_else(|| self.animation.borrow().state().value())
215    }
216
217    pub(crate) fn liquid_pose(&self) -> LiquidPose {
218        self.dynamics.update_pointer((self.value(), 0.0))
219    }
220
221    pub(crate) fn is_dragging(&self) -> bool {
222        self.pointer.get().is_some()
223    }
224}
225
226#[composable]
227pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
228    with_current_composer(|composer| {
229        let runtime = composer.runtime_handle();
230        composer
231            .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime)))
232            .with(Rc::clone)
233    })
234}
235
236pub(crate) struct LiquidLensGesture {
237    pub axis: Rc<LiquidDragAxis>,
238    pub cell_width: f32,
239    pub count: usize,
240    pub tap_slop: f32,
241    pub drag_left: Rc<dyn Fn(f32) -> f32>,
242    pub rest_left: Rc<dyn Fn(usize) -> f32>,
243    pub selected: usize,
244    pub on_pressed: Rc<dyn Fn(bool)>,
245    pub on_touch: Rc<dyn Fn(f32, f32)>,
246    pub on_select: Rc<dyn Fn(usize)>,
247}
248
249impl LiquidLensGesture {
250    fn commit_index(&self, position: f32) -> usize {
251        ((position / self.cell_width.max(1.0)).floor() as isize)
252            .clamp(0, self.count.saturating_sub(1) as isize) as usize
253    }
254}
255
256pub(crate) async fn liquid_lens_gesture(
257    scope: cranpose_ui::PointerInputScope,
258    gesture: LiquidLensGesture,
259) {
260    use cranpose_foundation::{PointerEventKind, PointerId};
261    use cranpose_services::{HapticFeedback, default_haptics};
262
263    scope
264        .await_pointer_event_scope(|await_scope| async move {
265            let mut down_x = 0.0f32;
266            let mut moved = false;
267            let mut active_pointer = Option::<PointerId>::None;
268            loop {
269                let event = await_scope.await_pointer_event().await;
270                match event.kind {
271                    PointerEventKind::Down if active_pointer.is_none() => {
272                        active_pointer = Some(event.id);
273                        down_x = event.position.x;
274                        moved = false;
275                        (gesture.on_pressed)(true);
276                        (gesture.on_touch)(event.position.x, event.position.y);
277                        default_haptics().perform(HapticFeedback::Selection);
278                        event.consume();
279                    }
280                    PointerEventKind::Move if active_pointer == Some(event.id) => {
281                        moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
282                        if moved {
283                            let target = (gesture.drag_left)(event.position.x);
284                            if gesture.axis.is_dragging() {
285                                gesture.axis.move_to(target, event.time_ms);
286                            } else if (gesture.axis.value() - target).abs()
287                                <= gesture.cell_width * 0.6
288                            {
289                                gesture.axis.begin(gesture.axis.value(), event.time_ms);
290                                gesture.axis.move_to(target, event.time_ms);
291                            } else {
292                                gesture.axis.settle_to(target, LiquidMotion::glide());
293                            }
294                        }
295                        (gesture.on_touch)(event.position.x, event.position.y);
296                        event.consume();
297                    }
298                    PointerEventKind::Up if active_pointer == Some(event.id) => {
299                        active_pointer = None;
300                        (gesture.on_pressed)(false);
301                        let commit_x = if moved { event.position.x } else { down_x };
302                        let index = gesture.commit_index(commit_x);
303                        gesture.axis.release_to(
304                            (gesture.rest_left)(index),
305                            event.time_ms,
306                            LiquidMotion::glide(),
307                        );
308                        default_haptics().perform(HapticFeedback::ImpactLight);
309                        (gesture.on_select)(index);
310                        event.consume();
311                    }
312                    PointerEventKind::Cancel if active_pointer == Some(event.id) => {
313                        active_pointer = None;
314                        (gesture.on_pressed)(false);
315                        gesture.axis.release_to(
316                            (gesture.rest_left)(gesture.selected),
317                            event.time_ms,
318                            LiquidMotion::glide(),
319                        );
320                        event.consume();
321                    }
322                    _ => {}
323                }
324            }
325        })
326        .await;
327}
328
329/// Press feedback for glass controls, per the Liquid Glass law: touched glass
330/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
331/// TRANSPARENT (the returned content alpha dips while pressed, the reference
332/// "…" dots fading as the button lifts). Returns the pressed state so callers
333/// can also boost the specular highlight.
334///
335/// Apply the returned modifier *outside* the glass effect so the whole lens
336/// scales together; apply the content alpha to the label/icon layer.
337#[composable]
338pub fn liquid_press_scale(
339    modifier: Modifier,
340    interaction_source: MutableInteractionSource,
341    pressed_scale: f32,
342) -> (Modifier, State<bool>, State<f32>) {
343    let pressed = interaction_source.collectIsPressedAsState();
344    let scale = cranpose_animation::animateFloatAsState(
345        if pressed.get() {
346            pressed_scale.max(1.0)
347        } else {
348            1.0
349        },
350        LiquidMotion::snappy(),
351        "liquid-press-scale",
352    );
353    let content_alpha = cranpose_animation::animateFloatAsState(
354        if pressed.get() { 0.35 } else { 1.0 },
355        LiquidMotion::smooth(),
356        "liquid-press-content",
357    );
358    let modifier = modifier.graphics_layer(move || {
359        let scale = scale.get();
360        GraphicsLayer {
361            scale_x: scale,
362            scale_y: scale,
363            ..Default::default()
364        }
365    });
366    (modifier, pressed, content_alpha)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
374        let runtime =
375            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
376        let axis = LiquidDragAxis::new(initial, runtime.handle());
377        (runtime, axis)
378    }
379
380    #[test]
381    fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
382        let (_runtime, axis) = axis(10.0);
383        axis.begin(20.0, Some(0));
384        assert_eq!(axis.value(), 20.0);
385        axis.move_to(180.0, Some(16));
386        assert_eq!(axis.value(), 180.0);
387    }
388
389    #[test]
390    fn direct_manipulation_owns_visual_selection_until_the_lens_reaches_state() {
391        assert_eq!(liquid_visual_index(2, 0.0, 78.0, 4, false), 2);
392        assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
393        assert_eq!(liquid_visual_index(0, 1.6 * 78.0, 78.0, 4, true), 2);
394        assert_eq!(liquid_visual_index(0, 1.4 * 78.0, 78.0, 4, true), 1);
395        assert_eq!(liquid_visual_index(0, 2.71 * 78.0, 78.0, 4, true), 3);
396        assert_eq!(liquid_visual_index(0, 3.0 * 78.0, 78.0, 4, true), 3);
397        assert_eq!(liquid_visual_index(0, 99.0 * 78.0, 78.0, 4, true), 3);
398        assert_eq!(liquid_visual_index(9, f32::NAN, 78.0, 4, true), 3);
399        assert_eq!(liquid_visual_index(0, 78.0, 0.0, 4, true), 0);
400
401        assert!(liquid_axis_owns_visual_selection(true, 0.0, 0.0, 78.0));
402        assert!(liquid_axis_owns_visual_selection(false, 78.0, 0.0, 78.0));
403        assert!(!liquid_axis_owns_visual_selection(
404            false,
405            78.0 * 0.05,
406            0.0,
407            78.0,
408        ));
409    }
410
411    #[test]
412    fn pointer_sample_excites_the_incompressible_pose_before_render() {
413        let (_runtime, axis) = axis(0.0);
414        axis.begin(0.0, Some(0));
415        axis.move_to(14.0, Some(16));
416        let pose = axis.liquid_pose();
417        let deformation = (pose.stretch - 1.0).abs();
418        assert!(
419            (0.08..=0.12).contains(&deformation),
420            "the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
421        );
422        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
423        assert_eq!(axis.value(), 14.0);
424    }
425
426    #[test]
427    fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
428        let (_runtime, axis) = axis(0.0);
429        axis.runtime.drain_frame_callbacks(1_000_000);
430        axis.begin(0.0, Some(0));
431        axis.move_to(14.0, Some(16));
432        let sampled = axis.liquid_pose();
433
434        axis.runtime.drain_frame_callbacks(17_000_000);
435        let next_frame = axis.liquid_pose();
436
437        assert!(
438            (next_frame.stretch - sampled.stretch).abs() < 0.08,
439            "a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
440        );
441        assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
442    }
443
444    #[test]
445    fn controlled_retargets_wait_until_direct_manipulation_ends() {
446        let (_runtime, axis) = axis(10.0);
447        axis.begin(40.0, Some(0));
448        axis.settle_to(90.0, LiquidMotion::snappy());
449        assert_eq!(axis.value(), 40.0);
450        axis.release_to(90.0, Some(16), LiquidMotion::snappy());
451        assert!(!axis.is_dragging());
452        assert_eq!(axis.animation.borrow().target(), 90.0);
453    }
454
455    #[test]
456    fn continuous_release_stops_translation_without_erasing_fluid_velocity() {
457        let (_runtime, axis) = axis(0.0);
458        axis.runtime.drain_frame_callbacks(1_000_000);
459        axis.begin(0.0, Some(0));
460        axis.move_to(80.0, Some(16));
461        let moving = axis.liquid_pose();
462
463        axis.finish_at(80.0, Some(17));
464        assert!(!axis.is_dragging());
465        assert_eq!(axis.value(), 80.0);
466        assert!(moving.speed > 0.0);
467
468        let mut relaxed = moving;
469        for frame in 2..=12 {
470            axis.runtime.drain_frame_callbacks(frame * 17_000_000);
471            assert_eq!(axis.value(), 80.0, "frame {frame} backtracked");
472            relaxed = axis.liquid_pose();
473        }
474        assert!(
475            relaxed.speed < moving.speed,
476            "shape velocity must relax even though translation stops"
477        );
478    }
479
480    #[test]
481    fn released_flight_is_critically_damped() {
482        let AnimationType::Spring(spec) = LiquidMotion::glide() else {
483            panic!("released flight must use a spring");
484        };
485        assert_eq!(spec.damping_ratio, 1.0);
486        assert_eq!(spec.stiffness, 500.0);
487    }
488
489    fn spring_of(animation: AnimationType) -> cranpose_animation::SpringSpec {
490        match animation {
491            AnimationType::Spring(spec) => spec,
492            other => panic!("expected a spring, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn a_stretching_blob_runs_its_leading_edge_ahead_of_its_trailing_edge() {
498        let leading = spring_of(LiquidMotion::blob_leading());
499        let trailing = spring_of(LiquidMotion::blob_trailing());
500        assert!(
501            leading.stiffness > trailing.stiffness,
502            "the edge that runs ahead has to be the stiffer one: \
503             leading {} vs trailing {}",
504            leading.stiffness,
505            trailing.stiffness
506        );
507        assert!(
508            leading.damping_ratio < 1.0 && trailing.damping_ratio < 1.0,
509            "both edges stay under-damped so the droplet keeps its elongation"
510        );
511    }
512
513    #[test]
514    fn every_named_motion_is_a_settled_spring() {
515        for (name, animation) in [
516            ("snappy", LiquidMotion::snappy()),
517            ("bouncy", LiquidMotion::bouncy()),
518            ("smooth", LiquidMotion::smooth()),
519            ("blob_leading", LiquidMotion::blob_leading()),
520            ("blob_trailing", LiquidMotion::blob_trailing()),
521            ("glide", LiquidMotion::glide()),
522        ] {
523            let spec = spring_of(animation);
524            assert!(spec.stiffness > 0.0, "{name} needs a stiffness");
525            assert!(spec.damping_ratio > 0.0, "{name} needs damping");
526            assert_eq!(spec.delay_millis, 0, "{name} starts on the frame it is set");
527        }
528    }
529}