1use 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 ((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
54pub struct LiquidMotion;
57
58impl LiquidMotion {
59 pub fn snappy() -> AnimationType {
61 spring(0.85, 900.0)
62 }
63
64 pub fn bouncy() -> AnimationType {
66 spring(0.55, 500.0)
67 }
68
69 pub fn smooth() -> AnimationType {
71 spring(1.0, 400.0)
72 }
73
74 pub fn blob_leading() -> AnimationType {
76 spring(0.8, 900.0)
77 }
78
79 pub fn blob_trailing() -> AnimationType {
82 spring(0.9, 380.0)
83 }
84
85 pub fn glide() -> AnimationType {
90 spring(1.0, 500.0)
91 }
92}
93
94pub(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 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
245pub(crate) struct LiquidLensGesture {
248 pub axis: Rc<LiquidDragAxis>,
249 pub cell_width: f32,
251 pub count: usize,
252 pub tap_slop: f32,
254 pub drag_left: Rc<dyn Fn(f32) -> f32>,
257 pub rest_left: Rc<dyn Fn(usize) -> f32>,
260 pub selected: usize,
262 pub on_pressed: Rc<dyn Fn(bool)>,
265 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
278pub(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 (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 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 gesture.axis.begin(gesture.axis.value(), event.time_ms);
331 gesture.axis.move_to(target, event.time_ms);
332 } else {
333 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#[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 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 assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
439 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 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}