1use 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
49pub struct LiquidMotion;
52
53impl LiquidMotion {
54 pub fn snappy() -> AnimationType {
56 spring(0.85, 900.0)
57 }
58
59 pub fn bouncy() -> AnimationType {
61 spring(0.55, 500.0)
62 }
63
64 pub fn smooth() -> AnimationType {
66 spring(1.0, 400.0)
67 }
68
69 pub fn blob_leading() -> AnimationType {
71 spring(0.8, 900.0)
72 }
73
74 pub fn blob_trailing() -> AnimationType {
77 spring(0.9, 380.0)
78 }
79
80 pub fn glide() -> AnimationType {
85 spring(1.0, 500.0)
86 }
87}
88
89pub(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 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
240pub(crate) struct LiquidLensGesture {
243 pub axis: Rc<LiquidDragAxis>,
244 pub cell_width: f32,
246 pub count: usize,
247 pub tap_slop: f32,
249 pub drag_left: Rc<dyn Fn(f32) -> f32>,
252 pub rest_left: Rc<dyn Fn(usize) -> f32>,
255 pub selected: usize,
257 pub on_pressed: Rc<dyn Fn(bool)>,
260 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
273pub(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 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#[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 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 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}