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 cranpose_animation::{spring, tween, Animatable, AnimationType, Easing};
5use cranpose_core::{with_current_composer, RuntimeHandle, State};
6use cranpose_foundation::VelocityTracker1D;
7use cranpose_macros::composable;
8use cranpose_ui::Modifier;
9use cranpose_ui::MutableInteractionSource;
10use cranpose_ui_graphics::GraphicsLayer;
11use std::cell::{Cell, RefCell};
12use std::rc::Rc;
13
14use crate::dynamics::{LiquidDynamics, LiquidPose};
15
16const FLUID_RELAX_MS: u64 = 420;
17const VISUAL_HANDOFF_TOLERANCE_IN_ITEMS: f32 = 0.12;
18
19pub(crate) fn liquid_visual_index(
20    selected: usize,
21    lens_position: f32,
22    item_width: f32,
23    count: usize,
24    lens_owns_selection: bool,
25) -> usize {
26    if count == 0 {
27        return 0;
28    }
29    let selected = selected.min(count - 1);
30    if !lens_owns_selection || !lens_position.is_finite() || item_width <= f32::EPSILON {
31        return selected;
32    }
33    (lens_position / item_width)
34        .floor()
35        .clamp(0.0, count.saturating_sub(1) as f32) as usize
36}
37
38pub(crate) fn liquid_axis_owns_visual_selection(
39    direct: bool,
40    lens_position: f32,
41    state_position: f32,
42    item_width: f32,
43) -> bool {
44    direct
45        || (lens_position - state_position).abs()
46            > item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
47}
48
49/// Named springs used across the Liquid components (value-space, velocity
50/// preserving).
51pub struct LiquidMotion;
52
53impl LiquidMotion {
54    /// Snappy interactions: presses, toggles, selection moves.
55    pub fn snappy() -> AnimationType {
56        spring(0.85, 900.0)
57    }
58
59    /// The droplet feel: visible overshoot for morphing shapes.
60    pub fn bouncy() -> AnimationType {
61        spring(0.55, 500.0)
62    }
63
64    /// Gentle settle for large surfaces (sheets, menus).
65    pub fn smooth() -> AnimationType {
66        spring(1.0, 400.0)
67    }
68
69    /// The leading edge of a stretching selection blob (runs ahead).
70    pub fn blob_leading() -> AnimationType {
71        spring(0.8, 900.0)
72    }
73
74    /// The trailing edge of a stretching selection blob (drags behind, giving
75    /// the droplet elongation while in motion).
76    pub fn blob_trailing() -> AnimationType {
77        spring(0.9, 380.0)
78    }
79
80    /// A released lens flying to its committed slot: the reference
81    /// bottom-bar transfer arrives in ~170 ms (on-white-click sheet,
82    /// f_0040 departure to f_0050 arrival at 60 fps) with no visible
83    /// overshoot; the optical settle continues after the geometry lands.
84    pub fn glide() -> AnimationType {
85        spring(1.0, 500.0)
86    }
87}
88
89/// One travelling coordinate with strict direct-manipulation semantics.
90/// Pointer samples are visible immediately; the animation channel is used
91/// only after release or for controlled-state changes while idle.
92pub(crate) struct LiquidDragAxis {
93    animation: RefCell<Animatable<f32>>,
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) -> Self {
104        Self {
105            animation: RefCell::new(Animatable::new(initial, runtime.clone())),
106            pointer: Cell::new(None),
107            velocity: RefCell::new(VelocityTracker1D::new()),
108            dynamics: LiquidDynamics::new(runtime.clone()),
109            fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
110            runtime,
111            last_sample_ms: Cell::new(None),
112        }
113    }
114
115    fn arm_fluid_frames(&self) {
116        let mut clock = self.fluid_clock.borrow_mut();
117        clock.snapTo(0.0);
118        clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
119    }
120
121    fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
122        let candidate = event_time_ms
123            .or_else(|| {
124                self.runtime
125                    .last_frame_time_nanos()
126                    .map(|nanos| (nanos / 1_000_000) as i64)
127            })
128            .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
129        let monotonic = self
130            .last_sample_ms
131            .get()
132            .map_or(candidate, |last| candidate.max(last + 1));
133        self.last_sample_ms.set(Some(monotonic));
134        monotonic
135    }
136
137    pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
138        let time_ms = self.sample_time_ms(event_time_ms);
139        let mut velocity = self.velocity.borrow_mut();
140        velocity.reset();
141        velocity.add_data_point(time_ms, position);
142        self.pointer.set(Some(position));
143        self.animation.borrow_mut().snapTo(position);
144        self.dynamics.anchor_pointer((position, 0.0));
145        self.arm_fluid_frames();
146    }
147
148    pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
149        if self.pointer.get().is_none() {
150            return;
151        }
152        let previous_time_ms = self.last_sample_ms.get();
153        let time_ms = self.sample_time_ms(event_time_ms);
154        self.velocity.borrow_mut().add_data_point(time_ms, position);
155        self.pointer.set(Some(position));
156        self.animation.borrow_mut().snapTo(position);
157        if let Some(previous_time_ms) = previous_time_ms {
158            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
159            self.dynamics.advance_pointer((position, 0.0), dt);
160        }
161        self.arm_fluid_frames();
162    }
163
164    pub(crate) fn release_to(
165        &self,
166        target: f32,
167        event_time_ms: Option<i64>,
168        animation: AnimationType,
169    ) {
170        let Some(position) = self.pointer.take() else {
171            self.settle_to(target, animation);
172            return;
173        };
174        let time_ms = self.sample_time_ms(event_time_ms);
175        self.velocity.borrow_mut().add_data_point(time_ms, position);
176        let release_velocity = self.velocity.borrow().calculate_velocity_with_max(8_000.0);
177        self.dynamics.release_pointer();
178        self.animation
179            .borrow_mut()
180            .animate_to_with_velocity(target, release_velocity, animation);
181    }
182
183    /// End direct manipulation at its final sample without creating a
184    /// translational spring flight. Continuous controls stop at the finger,
185    /// while the fluid integrator retains and relaxes the sampled velocity.
186    pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
187        let Some(_) = self.pointer.get() else {
188            self.animation.borrow_mut().snapTo(position);
189            return;
190        };
191        let previous_time_ms = self.last_sample_ms.get();
192        let time_ms = self.sample_time_ms(event_time_ms);
193        self.velocity.borrow_mut().add_data_point(time_ms, position);
194        if let Some(previous_time_ms) = previous_time_ms {
195            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
196            self.dynamics.advance_pointer((position, 0.0), dt);
197        }
198        self.pointer.set(None);
199        self.animation.borrow_mut().snapTo(position);
200        self.dynamics.release_pointer();
201        self.arm_fluid_frames();
202    }
203
204    pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
205        if self.pointer.get().is_some() {
206            return;
207        }
208        let mut value = self.animation.borrow_mut();
209        if (value.target() - target).abs() > f32::EPSILON {
210            value.animateTo(target, animation);
211        }
212    }
213
214    pub(crate) fn value(&self) -> f32 {
215        let _ = self.fluid_clock.borrow().state().value();
216        self.pointer
217            .get()
218            .unwrap_or_else(|| self.animation.borrow().state().value())
219    }
220
221    pub(crate) fn liquid_pose(&self) -> LiquidPose {
222        self.dynamics.update_pointer((self.value(), 0.0))
223    }
224
225    pub(crate) fn is_dragging(&self) -> bool {
226        self.pointer.get().is_some()
227    }
228}
229
230#[composable]
231pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
232    with_current_composer(|composer| {
233        let runtime = composer.runtime_handle();
234        composer
235            .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime)))
236            .with(Rc::clone)
237    })
238}
239
240/// Wiring for [`liquid_lens_gesture`]: the one tap/swipe state machine every
241/// lens-carrying strip control shares (tab bar, segmented control).
242pub(crate) struct LiquidLensGesture {
243    pub axis: Rc<LiquidDragAxis>,
244    /// Cell pitch, for committing a release position to an index.
245    pub cell_width: f32,
246    pub count: usize,
247    /// Pointer travel below this is a tap, not a swipe.
248    pub tap_slop: f32,
249    /// Clamped lens-left for a live pointer x (drag bounds — may allow
250    /// overdrag past the ends, e.g. toward a bar accessory).
251    pub drag_left: Rc<dyn Fn(f32) -> f32>,
252    /// Settled lens-left for a committed index (rest bounds — keeps the
253    /// bubble inside the pill).
254    pub rest_left: Rc<dyn Fn(usize) -> f32>,
255    /// The index the control rests on if the gesture cancels.
256    pub selected: usize,
257    /// Pressed-state feedback (lift, highlight). Called with `true` on
258    /// touch-down and `false` on release/cancel.
259    pub on_pressed: Rc<dyn Fn(bool)>,
260    /// Live touch point feedback (the under-finger glow). No-op for
261    /// controls without one.
262    pub on_touch: Rc<dyn Fn(f32, f32)>,
263    pub on_select: Rc<dyn Fn(usize)>,
264}
265
266impl LiquidLensGesture {
267    fn commit_index(&self, position: f32) -> usize {
268        ((position / self.cell_width.max(1.0)).floor() as isize)
269            .clamp(0, self.count.saturating_sub(1) as isize) as usize
270    }
271}
272
273/// Drives one pointer interaction for a lens strip: touch-down ATTRACTS the
274/// lens toward the finger (a glide, never a teleport), movement past the
275/// slop attaches the lens directly, release commits the covered cell and
276/// glides the lens to its resting place, cancel returns to the controlled
277/// selection. The tab bar and segmented control both run exactly this
278/// machine — only their clamp rules and feedback hooks differ.
279pub(crate) async fn liquid_lens_gesture(
280    scope: cranpose_ui::PointerInputScope,
281    gesture: LiquidLensGesture,
282) {
283    use cranpose_foundation::{PointerEventKind, PointerId};
284    use cranpose_services::{default_haptics, HapticFeedback};
285
286    scope
287        .await_pointer_event_scope(|await_scope| async move {
288            let mut down_x = 0.0f32;
289            let mut moved = false;
290            let mut active_pointer = Option::<PointerId>::None;
291            loop {
292                let event = await_scope.await_pointer_event().await;
293                match event.kind {
294                    PointerEventKind::Down if active_pointer.is_none() => {
295                        active_pointer = Some(event.id);
296                        down_x = event.position.x;
297                        moved = false;
298                        (gesture.on_pressed)(true);
299                        (gesture.on_touch)(event.position.x, event.position.y);
300                        gesture.axis.release_to(
301                            (gesture.drag_left)(event.position.x),
302                            event.time_ms,
303                            LiquidMotion::glide(),
304                        );
305                        default_haptics().perform(HapticFeedback::Selection);
306                        event.consume();
307                    }
308                    PointerEventKind::Move if active_pointer == Some(event.id) => {
309                        moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
310                        // Below the slop this is still a tap: keep the lens
311                        // anchored so release FLIES it. Feeding micro-jitter
312                        // into the direct axis teleports the lens to the
313                        // finger — the intermittent "snap instead of flight".
314                        if moved {
315                            if !gesture.axis.is_dragging() {
316                                gesture.axis.begin(gesture.axis.value(), event.time_ms);
317                            }
318                            gesture
319                                .axis
320                                .move_to((gesture.drag_left)(event.position.x), event.time_ms);
321                        }
322                        (gesture.on_touch)(event.position.x, event.position.y);
323                        event.consume();
324                    }
325                    PointerEventKind::Up if active_pointer == Some(event.id) => {
326                        active_pointer = None;
327                        (gesture.on_pressed)(false);
328                        let commit_x = if moved { event.position.x } else { down_x };
329                        let index = gesture.commit_index(commit_x);
330                        gesture.axis.release_to(
331                            (gesture.rest_left)(index),
332                            event.time_ms,
333                            LiquidMotion::glide(),
334                        );
335                        default_haptics().perform(HapticFeedback::ImpactLight);
336                        (gesture.on_select)(index);
337                        event.consume();
338                    }
339                    PointerEventKind::Cancel if active_pointer == Some(event.id) => {
340                        active_pointer = None;
341                        (gesture.on_pressed)(false);
342                        gesture.axis.release_to(
343                            (gesture.rest_left)(gesture.selected),
344                            event.time_ms,
345                            LiquidMotion::glide(),
346                        );
347                        event.consume();
348                    }
349                    _ => {}
350                }
351            }
352        })
353        .await;
354}
355
356/// Press feedback for glass controls, per the Liquid Glass law: touched glass
357/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
358/// TRANSPARENT (the returned content alpha dips while pressed, the reference
359/// "…" dots fading as the button lifts). Returns the pressed state so callers
360/// can also boost the specular highlight.
361///
362/// Apply the returned modifier *outside* the glass effect so the whole lens
363/// scales together; apply the content alpha to the label/icon layer.
364#[composable]
365pub fn liquid_press_scale(
366    modifier: Modifier,
367    interaction_source: MutableInteractionSource,
368    pressed_scale: f32,
369) -> (Modifier, State<bool>, State<f32>) {
370    let pressed = interaction_source.collectIsPressedAsState();
371    let scale = cranpose_animation::animateFloatAsState(
372        if pressed.get() {
373            pressed_scale.max(1.0)
374        } else {
375            1.0
376        },
377        LiquidMotion::snappy(),
378        "liquid-press-scale",
379    );
380    let content_alpha = cranpose_animation::animateFloatAsState(
381        // The reference down-state ghosts glyphs hard (the menu button's
382        // dots drop to ~30% while held).
383        if pressed.get() { 0.35 } else { 1.0 },
384        LiquidMotion::smooth(),
385        "liquid-press-content",
386    );
387    let modifier = modifier.graphics_layer(move || {
388        let scale = scale.get();
389        GraphicsLayer {
390            scale_x: scale,
391            scale_y: scale,
392            ..Default::default()
393        }
394    });
395    (modifier, pressed, content_alpha)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
403        let runtime =
404            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
405        let axis = LiquidDragAxis::new(initial, runtime.handle());
406        (runtime, axis)
407    }
408
409    #[test]
410    fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
411        let (_runtime, axis) = axis(10.0);
412        axis.begin(20.0, Some(0));
413        assert_eq!(axis.value(), 20.0);
414        axis.move_to(180.0, Some(16));
415        assert_eq!(axis.value(), 180.0);
416    }
417
418    #[test]
419    fn direct_manipulation_owns_visual_selection_until_the_lens_reaches_state() {
420        assert_eq!(liquid_visual_index(2, 0.0, 78.0, 4, false), 2);
421        assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
422        assert_eq!(liquid_visual_index(0, 2.71 * 78.0, 78.0, 4, true), 2);
423        assert_eq!(liquid_visual_index(0, 3.0 * 78.0, 78.0, 4, true), 3);
424        assert_eq!(liquid_visual_index(0, 99.0 * 78.0, 78.0, 4, true), 3);
425        assert_eq!(liquid_visual_index(9, f32::NAN, 78.0, 4, true), 3);
426        assert_eq!(liquid_visual_index(0, 78.0, 0.0, 4, true), 0);
427
428        assert!(liquid_axis_owns_visual_selection(true, 0.0, 0.0, 78.0));
429        assert!(liquid_axis_owns_visual_selection(false, 78.0, 0.0, 78.0));
430        assert!(!liquid_axis_owns_visual_selection(
431            false,
432            78.0 * 0.05,
433            0.0,
434            78.0,
435        ));
436    }
437
438    #[test]
439    fn pointer_sample_excites_the_incompressible_pose_before_render() {
440        let (_runtime, axis) = axis(0.0);
441        axis.begin(0.0, Some(0));
442        axis.move_to(14.0, Some(16));
443        let pose = axis.liquid_pose();
444        let deformation = (pose.stretch - 1.0).abs();
445        assert!(
446            (0.08..=0.12).contains(&deformation),
447            "the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
448        );
449        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
450        assert_eq!(axis.value(), 14.0);
451    }
452
453    #[test]
454    fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
455        let (_runtime, axis) = axis(0.0);
456        axis.runtime.drain_frame_callbacks(1_000_000);
457        axis.begin(0.0, Some(0));
458        axis.move_to(14.0, Some(16));
459        let sampled = axis.liquid_pose();
460
461        axis.runtime.drain_frame_callbacks(17_000_000);
462        let next_frame = axis.liquid_pose();
463
464        assert!(
465            (next_frame.stretch - sampled.stretch).abs() < 0.08,
466            "a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
467        );
468        assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
469    }
470
471    #[test]
472    fn controlled_retargets_wait_until_direct_manipulation_ends() {
473        let (_runtime, axis) = axis(10.0);
474        axis.begin(40.0, Some(0));
475        axis.settle_to(90.0, LiquidMotion::snappy());
476        assert_eq!(axis.value(), 40.0);
477        axis.release_to(90.0, Some(16), LiquidMotion::snappy());
478        assert!(!axis.is_dragging());
479        assert_eq!(axis.animation.borrow().target(), 90.0);
480    }
481
482    #[test]
483    fn continuous_release_stops_translation_without_erasing_fluid_velocity() {
484        let (_runtime, axis) = axis(0.0);
485        axis.runtime.drain_frame_callbacks(1_000_000);
486        axis.begin(0.0, Some(0));
487        axis.move_to(80.0, Some(16));
488        let moving = axis.liquid_pose();
489
490        axis.finish_at(80.0, Some(17));
491        assert!(!axis.is_dragging());
492        assert_eq!(axis.value(), 80.0);
493        assert!(moving.speed > 0.0);
494
495        let mut relaxed = moving;
496        for frame in 2..=12 {
497            axis.runtime.drain_frame_callbacks(frame * 17_000_000);
498            assert_eq!(axis.value(), 80.0, "frame {frame} backtracked");
499            relaxed = axis.liquid_pose();
500        }
501        assert!(
502            relaxed.speed < moving.speed,
503            "shape velocity must relax even though translation stops"
504        );
505    }
506
507    #[test]
508    fn released_flight_is_critically_damped() {
509        // Stiffness 500 ~= 90% travel at ~175 ms: the reference bottom-bar
510        // transfer arrives in ~170 ms with no visible overshoot.
511        let AnimationType::Spring(spec) = LiquidMotion::glide() else {
512            panic!("released flight must use a spring");
513        };
514        assert_eq!(spec.damping_ratio, 1.0);
515        assert_eq!(spec.stiffness, 500.0);
516    }
517}