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 * 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
52pub struct LiquidMotion;
55
56impl LiquidMotion {
57 pub fn snappy() -> AnimationType {
59 spring(0.85, 900.0)
60 }
61
62 pub fn bouncy() -> AnimationType {
64 spring(0.55, 500.0)
65 }
66
67 pub fn smooth() -> AnimationType {
69 spring(1.0, 400.0)
70 }
71
72 pub fn blob_leading() -> AnimationType {
74 spring(0.8, 900.0)
75 }
76
77 pub fn blob_trailing() -> AnimationType {
80 spring(0.9, 380.0)
81 }
82
83 pub fn glide() -> AnimationType {
88 spring(1.0, 500.0)
89 }
90}
91
92pub(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 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
243pub(crate) struct LiquidLensGesture {
246 pub axis: Rc<LiquidDragAxis>,
247 pub cell_width: f32,
249 pub count: usize,
250 pub tap_slop: f32,
252 pub drag_left: Rc<dyn Fn(f32) -> f32>,
255 pub rest_left: Rc<dyn Fn(usize) -> f32>,
258 pub selected: usize,
260 pub on_pressed: Rc<dyn Fn(bool)>,
263 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
276pub(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 (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 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 gesture.axis.begin(gesture.axis.value(), event.time_ms);
329 gesture.axis.move_to(target, event.time_ms);
330 } else {
331 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#[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 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 assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
437 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 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}