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)
36 .floor()
37 .clamp(0.0, count.saturating_sub(1) as f32) as usize
38}
39
40pub(crate) fn liquid_axis_owns_visual_selection(
41 direct: bool,
42 lens_position: f32,
43 state_position: f32,
44 item_width: f32,
45) -> bool {
46 direct
47 || (lens_position - state_position).abs()
48 > item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
49}
50
51pub struct LiquidMotion;
54
55impl LiquidMotion {
56 pub fn snappy() -> AnimationType {
58 spring(0.85, 900.0)
59 }
60
61 pub fn bouncy() -> AnimationType {
63 spring(0.55, 500.0)
64 }
65
66 pub fn smooth() -> AnimationType {
68 spring(1.0, 400.0)
69 }
70
71 pub fn blob_leading() -> AnimationType {
73 spring(0.8, 900.0)
74 }
75
76 pub fn blob_trailing() -> AnimationType {
79 spring(0.9, 380.0)
80 }
81
82 pub fn glide() -> AnimationType {
87 spring(1.0, 500.0)
88 }
89}
90
91pub(crate) struct LiquidDragAxis {
92 animation: RefCell<Animatable<f32>>,
93 pointer: Cell<Option<f32>>,
94 velocity: RefCell<VelocityTracker1D>,
95 runtime: RuntimeHandle,
96 last_sample_ms: Cell<Option<i64>>,
97 dynamics: LiquidDynamics,
98 fluid_clock: RefCell<Animatable<f32>>,
99}
100
101impl LiquidDragAxis {
102 fn new(initial: f32, runtime: RuntimeHandle) -> Self {
103 Self {
104 animation: RefCell::new(Animatable::new(initial, runtime.clone())),
105 pointer: Cell::new(None),
106 velocity: RefCell::new(VelocityTracker1D::new()),
107 dynamics: LiquidDynamics::new(runtime.clone()),
108 fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
109 runtime,
110 last_sample_ms: Cell::new(None),
111 }
112 }
113
114 fn arm_fluid_frames(&self) {
115 let mut clock = self.fluid_clock.borrow_mut();
116 clock.snapTo(0.0);
117 clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
118 }
119
120 fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
121 let candidate = event_time_ms
122 .or_else(|| {
123 self.runtime
124 .last_frame_time_nanos()
125 .map(|nanos| (nanos / 1_000_000) as i64)
126 })
127 .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
128 let monotonic = self
129 .last_sample_ms
130 .get()
131 .map_or(candidate, |last| candidate.max(last + 1));
132 self.last_sample_ms.set(Some(monotonic));
133 monotonic
134 }
135
136 pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
137 let time_ms = self.sample_time_ms(event_time_ms);
138 let mut velocity = self.velocity.borrow_mut();
139 velocity.reset();
140 velocity.add_data_point(time_ms, position);
141 self.pointer.set(Some(position));
142 self.animation.borrow_mut().snapTo(position);
143 self.dynamics.anchor_pointer((position, 0.0));
144 self.arm_fluid_frames();
145 }
146
147 pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
148 if self.pointer.get().is_none() {
149 return;
150 }
151 let previous_time_ms = self.last_sample_ms.get();
152 let time_ms = self.sample_time_ms(event_time_ms);
153 self.velocity.borrow_mut().add_data_point(time_ms, position);
154 self.pointer.set(Some(position));
155 self.animation.borrow_mut().snapTo(position);
156 if let Some(previous_time_ms) = previous_time_ms {
157 let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
158 self.dynamics.advance_pointer((position, 0.0), dt);
159 }
160 self.arm_fluid_frames();
161 }
162
163 pub(crate) fn release_to(
164 &self,
165 target: f32,
166 event_time_ms: Option<i64>,
167 animation: AnimationType,
168 ) {
169 let Some(position) = self.pointer.take() else {
170 self.settle_to(target, animation);
171 return;
172 };
173 let time_ms = self.sample_time_ms(event_time_ms);
174 self.velocity.borrow_mut().add_data_point(time_ms, position);
175 let release_velocity = self.velocity.borrow().calculate_velocity_with_max(8_000.0);
176 self.dynamics.release_pointer();
177 self.animation
178 .borrow_mut()
179 .animate_to_with_velocity(target, release_velocity, animation);
180 }
181
182 pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
183 let Some(_) = self.pointer.get() else {
184 self.animation.borrow_mut().snapTo(position);
185 return;
186 };
187 let previous_time_ms = self.last_sample_ms.get();
188 let time_ms = self.sample_time_ms(event_time_ms);
189 self.velocity.borrow_mut().add_data_point(time_ms, position);
190 if let Some(previous_time_ms) = previous_time_ms {
191 let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
192 self.dynamics.advance_pointer((position, 0.0), dt);
193 }
194 self.pointer.set(None);
195 self.animation.borrow_mut().snapTo(position);
196 self.dynamics.release_pointer();
197 self.arm_fluid_frames();
198 }
199
200 pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
201 if self.pointer.get().is_some() {
202 return;
203 }
204 let mut value = self.animation.borrow_mut();
205 if (value.target() - target).abs() > f32::EPSILON {
206 value.animateTo(target, animation);
207 }
208 }
209
210 pub(crate) fn value(&self) -> f32 {
211 let _ = self.fluid_clock.borrow().state().value();
212 self.pointer
213 .get()
214 .unwrap_or_else(|| self.animation.borrow().state().value())
215 }
216
217 pub(crate) fn liquid_pose(&self) -> LiquidPose {
218 self.dynamics.update_pointer((self.value(), 0.0))
219 }
220
221 pub(crate) fn is_dragging(&self) -> bool {
222 self.pointer.get().is_some()
223 }
224}
225
226#[composable]
227pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
228 with_current_composer(|composer| {
229 let runtime = composer.runtime_handle();
230 composer
231 .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime)))
232 .with(Rc::clone)
233 })
234}
235
236pub(crate) struct LiquidLensGesture {
237 pub axis: Rc<LiquidDragAxis>,
238 pub cell_width: f32,
239 pub count: usize,
240 pub tap_slop: f32,
241 pub drag_left: Rc<dyn Fn(f32) -> f32>,
242 pub rest_left: Rc<dyn Fn(usize) -> f32>,
243 pub selected: usize,
244 pub on_pressed: Rc<dyn Fn(bool)>,
245 pub on_touch: Rc<dyn Fn(f32, f32)>,
246 pub on_select: Rc<dyn Fn(usize)>,
247}
248
249impl LiquidLensGesture {
250 fn commit_index(&self, position: f32) -> usize {
251 ((position / self.cell_width.max(1.0)).floor() as isize)
252 .clamp(0, self.count.saturating_sub(1) as isize) as usize
253 }
254}
255
256pub(crate) async fn liquid_lens_gesture(
257 scope: cranpose_ui::PointerInputScope,
258 gesture: LiquidLensGesture,
259) {
260 use cranpose_foundation::{PointerEventKind, PointerId};
261 use cranpose_services::{HapticFeedback, default_haptics};
262
263 scope
264 .await_pointer_event_scope(|await_scope| async move {
265 let mut down_x = 0.0f32;
266 let mut moved = false;
267 let mut active_pointer = Option::<PointerId>::None;
268 loop {
269 let event = await_scope.await_pointer_event().await;
270 match event.kind {
271 PointerEventKind::Down if active_pointer.is_none() => {
272 active_pointer = Some(event.id);
273 down_x = event.position.x;
274 moved = false;
275 (gesture.on_pressed)(true);
276 (gesture.on_touch)(event.position.x, event.position.y);
277 default_haptics().perform(HapticFeedback::Selection);
278 event.consume();
279 }
280 PointerEventKind::Move if active_pointer == Some(event.id) => {
281 moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
282 if moved {
283 let target = (gesture.drag_left)(event.position.x);
284 if gesture.axis.is_dragging() {
285 gesture.axis.move_to(target, event.time_ms);
286 } else if (gesture.axis.value() - target).abs()
287 <= gesture.cell_width * 0.6
288 {
289 gesture.axis.begin(gesture.axis.value(), event.time_ms);
290 gesture.axis.move_to(target, event.time_ms);
291 } else {
292 gesture.axis.settle_to(target, LiquidMotion::glide());
293 }
294 }
295 (gesture.on_touch)(event.position.x, event.position.y);
296 event.consume();
297 }
298 PointerEventKind::Up if active_pointer == Some(event.id) => {
299 active_pointer = None;
300 (gesture.on_pressed)(false);
301 let commit_x = if moved { event.position.x } else { down_x };
302 let index = gesture.commit_index(commit_x);
303 gesture.axis.release_to(
304 (gesture.rest_left)(index),
305 event.time_ms,
306 LiquidMotion::glide(),
307 );
308 default_haptics().perform(HapticFeedback::ImpactLight);
309 (gesture.on_select)(index);
310 event.consume();
311 }
312 PointerEventKind::Cancel if active_pointer == Some(event.id) => {
313 active_pointer = None;
314 (gesture.on_pressed)(false);
315 gesture.axis.release_to(
316 (gesture.rest_left)(gesture.selected),
317 event.time_ms,
318 LiquidMotion::glide(),
319 );
320 event.consume();
321 }
322 _ => {}
323 }
324 }
325 })
326 .await;
327}
328
329#[composable]
338pub fn liquid_press_scale(
339 modifier: Modifier,
340 interaction_source: MutableInteractionSource,
341 pressed_scale: f32,
342) -> (Modifier, State<bool>, State<f32>) {
343 let pressed = interaction_source.collectIsPressedAsState();
344 let scale = cranpose_animation::animateFloatAsState(
345 if pressed.get() {
346 pressed_scale.max(1.0)
347 } else {
348 1.0
349 },
350 LiquidMotion::snappy(),
351 "liquid-press-scale",
352 );
353 let content_alpha = cranpose_animation::animateFloatAsState(
354 if pressed.get() { 0.35 } else { 1.0 },
355 LiquidMotion::smooth(),
356 "liquid-press-content",
357 );
358 let modifier = modifier.graphics_layer(move || {
359 let scale = scale.get();
360 GraphicsLayer {
361 scale_x: scale,
362 scale_y: scale,
363 ..Default::default()
364 }
365 });
366 (modifier, pressed, content_alpha)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
374 let runtime =
375 cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
376 let axis = LiquidDragAxis::new(initial, runtime.handle());
377 (runtime, axis)
378 }
379
380 #[test]
381 fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
382 let (_runtime, axis) = axis(10.0);
383 axis.begin(20.0, Some(0));
384 assert_eq!(axis.value(), 20.0);
385 axis.move_to(180.0, Some(16));
386 assert_eq!(axis.value(), 180.0);
387 }
388
389 #[test]
390 fn direct_manipulation_owns_visual_selection_until_the_lens_reaches_state() {
391 assert_eq!(liquid_visual_index(2, 0.0, 78.0, 4, false), 2);
392 assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
393 assert_eq!(liquid_visual_index(0, 1.6 * 78.0, 78.0, 4, true), 2);
394 assert_eq!(liquid_visual_index(0, 1.4 * 78.0, 78.0, 4, true), 1);
395 assert_eq!(liquid_visual_index(0, 2.71 * 78.0, 78.0, 4, true), 3);
396 assert_eq!(liquid_visual_index(0, 3.0 * 78.0, 78.0, 4, true), 3);
397 assert_eq!(liquid_visual_index(0, 99.0 * 78.0, 78.0, 4, true), 3);
398 assert_eq!(liquid_visual_index(9, f32::NAN, 78.0, 4, true), 3);
399 assert_eq!(liquid_visual_index(0, 78.0, 0.0, 4, true), 0);
400
401 assert!(liquid_axis_owns_visual_selection(true, 0.0, 0.0, 78.0));
402 assert!(liquid_axis_owns_visual_selection(false, 78.0, 0.0, 78.0));
403 assert!(!liquid_axis_owns_visual_selection(
404 false,
405 78.0 * 0.05,
406 0.0,
407 78.0,
408 ));
409 }
410
411 #[test]
412 fn pointer_sample_excites_the_incompressible_pose_before_render() {
413 let (_runtime, axis) = axis(0.0);
414 axis.begin(0.0, Some(0));
415 axis.move_to(14.0, Some(16));
416 let pose = axis.liquid_pose();
417 let deformation = (pose.stretch - 1.0).abs();
418 assert!(
419 (0.08..=0.12).contains(&deformation),
420 "the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
421 );
422 assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
423 assert_eq!(axis.value(), 14.0);
424 }
425
426 #[test]
427 fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
428 let (_runtime, axis) = axis(0.0);
429 axis.runtime.drain_frame_callbacks(1_000_000);
430 axis.begin(0.0, Some(0));
431 axis.move_to(14.0, Some(16));
432 let sampled = axis.liquid_pose();
433
434 axis.runtime.drain_frame_callbacks(17_000_000);
435 let next_frame = axis.liquid_pose();
436
437 assert!(
438 (next_frame.stretch - sampled.stretch).abs() < 0.08,
439 "a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
440 );
441 assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
442 }
443
444 #[test]
445 fn controlled_retargets_wait_until_direct_manipulation_ends() {
446 let (_runtime, axis) = axis(10.0);
447 axis.begin(40.0, Some(0));
448 axis.settle_to(90.0, LiquidMotion::snappy());
449 assert_eq!(axis.value(), 40.0);
450 axis.release_to(90.0, Some(16), LiquidMotion::snappy());
451 assert!(!axis.is_dragging());
452 assert_eq!(axis.animation.borrow().target(), 90.0);
453 }
454
455 #[test]
456 fn continuous_release_stops_translation_without_erasing_fluid_velocity() {
457 let (_runtime, axis) = axis(0.0);
458 axis.runtime.drain_frame_callbacks(1_000_000);
459 axis.begin(0.0, Some(0));
460 axis.move_to(80.0, Some(16));
461 let moving = axis.liquid_pose();
462
463 axis.finish_at(80.0, Some(17));
464 assert!(!axis.is_dragging());
465 assert_eq!(axis.value(), 80.0);
466 assert!(moving.speed > 0.0);
467
468 let mut relaxed = moving;
469 for frame in 2..=12 {
470 axis.runtime.drain_frame_callbacks(frame * 17_000_000);
471 assert_eq!(axis.value(), 80.0, "frame {frame} backtracked");
472 relaxed = axis.liquid_pose();
473 }
474 assert!(
475 relaxed.speed < moving.speed,
476 "shape velocity must relax even though translation stops"
477 );
478 }
479
480 #[test]
481 fn released_flight_is_critically_damped() {
482 let AnimationType::Spring(spec) = LiquidMotion::glide() else {
483 panic!("released flight must use a spring");
484 };
485 assert_eq!(spec.damping_ratio, 1.0);
486 assert_eq!(spec.stiffness, 500.0);
487 }
488
489 fn spring_of(animation: AnimationType) -> cranpose_animation::SpringSpec {
490 match animation {
491 AnimationType::Spring(spec) => spec,
492 other => panic!("expected a spring, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn a_stretching_blob_runs_its_leading_edge_ahead_of_its_trailing_edge() {
498 let leading = spring_of(LiquidMotion::blob_leading());
499 let trailing = spring_of(LiquidMotion::blob_trailing());
500 assert!(
501 leading.stiffness > trailing.stiffness,
502 "the edge that runs ahead has to be the stiffer one: \
503 leading {} vs trailing {}",
504 leading.stiffness,
505 trailing.stiffness
506 );
507 assert!(
508 leading.damping_ratio < 1.0 && trailing.damping_ratio < 1.0,
509 "both edges stay under-damped so the droplet keeps its elongation"
510 );
511 }
512
513 #[test]
514 fn every_named_motion_is_a_settled_spring() {
515 for (name, animation) in [
516 ("snappy", LiquidMotion::snappy()),
517 ("bouncy", LiquidMotion::bouncy()),
518 ("smooth", LiquidMotion::smooth()),
519 ("blob_leading", LiquidMotion::blob_leading()),
520 ("blob_trailing", LiquidMotion::blob_trailing()),
521 ("glide", LiquidMotion::glide()),
522 ] {
523 let spec = spring_of(animation);
524 assert!(spec.stiffness > 0.0, "{name} needs a stiffness");
525 assert!(spec.damping_ratio > 0.0, "{name} needs damping");
526 assert_eq!(spec.delay_millis, 0, "{name} starts on the frame it is set");
527 }
528 }
529}