use cranpose_animation::{spring, tween, Animatable, AnimationType, Easing};
use cranpose_core::{with_current_composer, RuntimeHandle, State};
use cranpose_foundation::VelocityTracker1D;
use cranpose_macros::composable;
use cranpose_ui::Modifier;
use cranpose_ui::MutableInteractionSource;
use cranpose_ui_graphics::GraphicsLayer;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::dynamics::{LiquidDynamics, LiquidPose};
const FLUID_RELAX_MS: u64 = 420;
const VISUAL_HANDOFF_TOLERANCE_IN_ITEMS: f32 = 0.12;
pub(crate) fn liquid_visual_index(
selected: usize,
lens_position: f32,
item_width: f32,
count: usize,
lens_owns_selection: bool,
) -> usize {
if count == 0 {
return 0;
}
let selected = selected.min(count - 1);
if !lens_owns_selection || !lens_position.is_finite() || item_width <= f32::EPSILON {
return selected;
}
(lens_position / item_width)
.floor()
.clamp(0.0, count.saturating_sub(1) as f32) as usize
}
pub(crate) fn liquid_axis_owns_visual_selection(
direct: bool,
lens_position: f32,
state_position: f32,
item_width: f32,
) -> bool {
direct
|| (lens_position - state_position).abs()
> item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
}
pub struct LiquidMotion;
impl LiquidMotion {
pub fn snappy() -> AnimationType {
spring(0.85, 900.0)
}
pub fn bouncy() -> AnimationType {
spring(0.55, 500.0)
}
pub fn smooth() -> AnimationType {
spring(1.0, 400.0)
}
pub fn blob_leading() -> AnimationType {
spring(0.8, 900.0)
}
pub fn blob_trailing() -> AnimationType {
spring(0.9, 380.0)
}
pub fn glide() -> AnimationType {
spring(1.0, 500.0)
}
}
pub(crate) struct LiquidDragAxis {
animation: RefCell<Animatable<f32>>,
pointer: Cell<Option<f32>>,
velocity: RefCell<VelocityTracker1D>,
runtime: RuntimeHandle,
last_sample_ms: Cell<Option<i64>>,
dynamics: LiquidDynamics,
fluid_clock: RefCell<Animatable<f32>>,
}
impl LiquidDragAxis {
fn new(initial: f32, runtime: RuntimeHandle) -> Self {
Self {
animation: RefCell::new(Animatable::new(initial, runtime.clone())),
pointer: Cell::new(None),
velocity: RefCell::new(VelocityTracker1D::new()),
dynamics: LiquidDynamics::new(runtime.clone()),
fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
runtime,
last_sample_ms: Cell::new(None),
}
}
fn arm_fluid_frames(&self) {
let mut clock = self.fluid_clock.borrow_mut();
clock.snapTo(0.0);
clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
}
fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
let candidate = event_time_ms
.or_else(|| {
self.runtime
.last_frame_time_nanos()
.map(|nanos| (nanos / 1_000_000) as i64)
})
.unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
let monotonic = self
.last_sample_ms
.get()
.map_or(candidate, |last| candidate.max(last + 1));
self.last_sample_ms.set(Some(monotonic));
monotonic
}
pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
let time_ms = self.sample_time_ms(event_time_ms);
let mut velocity = self.velocity.borrow_mut();
velocity.reset();
velocity.add_data_point(time_ms, position);
self.pointer.set(Some(position));
self.animation.borrow_mut().snapTo(position);
self.dynamics.anchor_pointer((position, 0.0));
self.arm_fluid_frames();
}
pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
if self.pointer.get().is_none() {
return;
}
let previous_time_ms = self.last_sample_ms.get();
let time_ms = self.sample_time_ms(event_time_ms);
self.velocity.borrow_mut().add_data_point(time_ms, position);
self.pointer.set(Some(position));
self.animation.borrow_mut().snapTo(position);
if let Some(previous_time_ms) = previous_time_ms {
let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
self.dynamics.advance_pointer((position, 0.0), dt);
}
self.arm_fluid_frames();
}
pub(crate) fn release_to(
&self,
target: f32,
event_time_ms: Option<i64>,
animation: AnimationType,
) {
let Some(position) = self.pointer.take() else {
self.settle_to(target, animation);
return;
};
let time_ms = self.sample_time_ms(event_time_ms);
self.velocity.borrow_mut().add_data_point(time_ms, position);
let release_velocity = self.velocity.borrow().calculate_velocity_with_max(8_000.0);
self.dynamics.release_pointer();
self.animation
.borrow_mut()
.animate_to_with_velocity(target, release_velocity, animation);
}
pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
let Some(_) = self.pointer.get() else {
self.animation.borrow_mut().snapTo(position);
return;
};
let previous_time_ms = self.last_sample_ms.get();
let time_ms = self.sample_time_ms(event_time_ms);
self.velocity.borrow_mut().add_data_point(time_ms, position);
if let Some(previous_time_ms) = previous_time_ms {
let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
self.dynamics.advance_pointer((position, 0.0), dt);
}
self.pointer.set(None);
self.animation.borrow_mut().snapTo(position);
self.dynamics.release_pointer();
self.arm_fluid_frames();
}
pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
if self.pointer.get().is_some() {
return;
}
let mut value = self.animation.borrow_mut();
if (value.target() - target).abs() > f32::EPSILON {
value.animateTo(target, animation);
}
}
pub(crate) fn value(&self) -> f32 {
let _ = self.fluid_clock.borrow().state().value();
self.pointer
.get()
.unwrap_or_else(|| self.animation.borrow().state().value())
}
pub(crate) fn liquid_pose(&self) -> LiquidPose {
self.dynamics.update_pointer((self.value(), 0.0))
}
pub(crate) fn is_dragging(&self) -> bool {
self.pointer.get().is_some()
}
}
#[composable]
pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
with_current_composer(|composer| {
let runtime = composer.runtime_handle();
composer
.remember(move || Rc::new(LiquidDragAxis::new(initial, runtime)))
.with(Rc::clone)
})
}
#[composable]
pub fn liquid_press_scale(
modifier: Modifier,
interaction_source: MutableInteractionSource,
pressed_scale: f32,
) -> (Modifier, State<bool>, State<f32>) {
let pressed = interaction_source.collectIsPressedAsState();
let scale = cranpose_animation::animateFloatAsState(
if pressed.get() {
pressed_scale.max(1.0)
} else {
1.0
},
LiquidMotion::snappy(),
"liquid-press-scale",
);
let content_alpha = cranpose_animation::animateFloatAsState(
if pressed.get() { 0.35 } else { 1.0 },
LiquidMotion::smooth(),
"liquid-press-content",
);
let modifier = modifier.graphics_layer(move || {
let scale = scale.get();
GraphicsLayer {
scale_x: scale,
scale_y: scale,
..Default::default()
}
});
(modifier, pressed, content_alpha)
}
#[cfg(test)]
mod tests {
use super::*;
fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
let runtime =
cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
let axis = LiquidDragAxis::new(initial, runtime.handle());
(runtime, axis)
}
#[test]
fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
let (_runtime, axis) = axis(10.0);
axis.begin(20.0, Some(0));
assert_eq!(axis.value(), 20.0);
axis.move_to(180.0, Some(16));
assert_eq!(axis.value(), 180.0);
}
#[test]
fn direct_manipulation_owns_visual_selection_until_the_lens_reaches_state() {
assert_eq!(liquid_visual_index(2, 0.0, 78.0, 4, false), 2);
assert_eq!(liquid_visual_index(0, 2.0 * 78.0, 78.0, 4, true), 2);
assert_eq!(liquid_visual_index(0, 2.71 * 78.0, 78.0, 4, true), 2);
assert_eq!(liquid_visual_index(0, 3.0 * 78.0, 78.0, 4, true), 3);
assert_eq!(liquid_visual_index(0, 99.0 * 78.0, 78.0, 4, true), 3);
assert_eq!(liquid_visual_index(9, f32::NAN, 78.0, 4, true), 3);
assert_eq!(liquid_visual_index(0, 78.0, 0.0, 4, true), 0);
assert!(liquid_axis_owns_visual_selection(true, 0.0, 0.0, 78.0));
assert!(liquid_axis_owns_visual_selection(false, 78.0, 0.0, 78.0));
assert!(!liquid_axis_owns_visual_selection(
false,
78.0 * 0.05,
0.0,
78.0,
));
}
#[test]
fn pointer_sample_excites_the_incompressible_pose_before_render() {
let (_runtime, axis) = axis(0.0);
axis.begin(0.0, Some(0));
axis.move_to(14.0, Some(16));
let pose = axis.liquid_pose();
let deformation = (pose.stretch - 1.0).abs();
assert!(
(0.08..=0.12).contains(&deformation),
"the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
);
assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
assert_eq!(axis.value(), 14.0);
}
#[test]
fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
let (_runtime, axis) = axis(0.0);
axis.runtime.drain_frame_callbacks(1_000_000);
axis.begin(0.0, Some(0));
axis.move_to(14.0, Some(16));
let sampled = axis.liquid_pose();
axis.runtime.drain_frame_callbacks(17_000_000);
let next_frame = axis.liquid_pose();
assert!(
(next_frame.stretch - sampled.stretch).abs() < 0.08,
"a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
);
assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
}
#[test]
fn controlled_retargets_wait_until_direct_manipulation_ends() {
let (_runtime, axis) = axis(10.0);
axis.begin(40.0, Some(0));
axis.settle_to(90.0, LiquidMotion::snappy());
assert_eq!(axis.value(), 40.0);
axis.release_to(90.0, Some(16), LiquidMotion::snappy());
assert!(!axis.is_dragging());
assert_eq!(axis.animation.borrow().target(), 90.0);
}
#[test]
fn continuous_release_stops_translation_without_erasing_fluid_velocity() {
let (_runtime, axis) = axis(0.0);
axis.runtime.drain_frame_callbacks(1_000_000);
axis.begin(0.0, Some(0));
axis.move_to(80.0, Some(16));
let moving = axis.liquid_pose();
axis.finish_at(80.0, Some(17));
assert!(!axis.is_dragging());
assert_eq!(axis.value(), 80.0);
assert!(moving.speed > 0.0);
let mut relaxed = moving;
for frame in 2..=12 {
axis.runtime.drain_frame_callbacks(frame * 17_000_000);
assert_eq!(axis.value(), 80.0, "frame {frame} backtracked");
relaxed = axis.liquid_pose();
}
assert!(
relaxed.speed < moving.speed,
"shape velocity must relax even though translation stops"
);
}
#[test]
fn released_flight_is_critically_damped() {
let AnimationType::Spring(spec) = LiquidMotion::glide() else {
panic!("released flight must use a spring");
};
assert_eq!(spec.damping_ratio, 1.0);
assert_eq!(spec.stiffness, 500.0);
}
}