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