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