#![allow(non_snake_case)]
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::composable;
use crate::modifier::Modifier;
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::popup::Popup;
use cranpose_animation::{spring, Animatable, AnimationSpec, AnimationType, Easing};
use cranpose_core::{remember, with_current_composer};
use cranpose_ui_graphics::{
liquid_loupe_effect, GraphicsLayer, LayerShape, LiquidLoupeSpec, Point, Rect,
RoundedCornerShape, Size,
};
pub const LOUPE_WIDTH: f32 = 117.0;
pub const LOUPE_HEIGHT: f32 = 82.0;
pub const LOUPE_RISE: f32 = 75.0;
pub const LOUPE_MAGNIFICATION: f32 = 1.25;
const LOUPE_COLLAPSE_MS: u64 = 120;
fn loupe_grow_spring() -> AnimationType {
spring(0.55, 320.0)
}
fn loupe_collapse_tween() -> AnimationType {
AnimationType::Tween(AnimationSpec::tween(LOUPE_COLLAPSE_MS, Easing::EaseInOut))
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LoupeTarget {
pub focus_x: f32,
pub line_mid_y: f32,
}
pub fn loupe_target_for_drag(
finger: Point,
line_bottom: f32,
line_height: f32,
) -> Option<LoupeTarget> {
let line_height = line_height.max(1.0);
Some(LoupeTarget {
focus_x: finger.x,
line_mid_y: line_bottom - 0.5 * line_height,
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct LoupePose {
width_frac: f32,
height_frac: f32,
rise_frac: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LoupePhase {
Birth,
Collapse,
}
fn loupe_pose(progress: f32, phase: LoupePhase) -> LoupePose {
let p = progress.max(0.0);
let bounded = p.min(1.0);
let (width_exponent, height_exponent, rise_exponent) = match phase {
LoupePhase::Birth => (0.60, 0.18, 0.60),
LoupePhase::Collapse => (0.80, 0.50, 1.0),
};
let width = if p <= 1.0 {
bounded.powf(width_exponent)
} else {
p
};
LoupePose {
width_frac: width,
height_frac: bounded.powf(height_exponent),
rise_frac: bounded.powf(rise_exponent),
}
}
fn loupe_optical_activity(progress: f32) -> f32 {
smoothstep01(progress)
}
fn smoothstep01(value: f32) -> f32 {
let t = value.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
struct LoupeState {
progress: RefCell<Animatable<f32>>,
follow_x: RefCell<Animatable<f32>>,
shown: RefCell<Option<LoupeTarget>>,
was_active: Cell<bool>,
}
#[composable]
pub fn SelectionLoupe(target: Option<LoupeTarget>) {
let state = remember(|| {
let runtime = with_current_composer(|composer| composer.runtime_handle());
Rc::new(LoupeState {
progress: RefCell::new(Animatable::new(0.0, runtime.clone())),
follow_x: RefCell::new(Animatable::new(f32::NAN, runtime)),
shown: RefCell::new(None),
was_active: Cell::new(false),
})
})
.with(Rc::clone);
let active = target.is_some();
if let Some(t) = target {
let fresh_grab = !state.was_active.get();
state.shown.replace(Some(t));
if fresh_grab {
let mut progress = state.progress.borrow_mut();
progress.snapTo(0.0);
progress.animateTo(1.0, loupe_grow_spring());
}
} else if state.was_active.get() {
let mut progress = state.progress.borrow_mut();
if progress.state().value() > 0.001 {
progress.animateTo(0.0, loupe_collapse_tween());
} else {
state.shown.replace(None);
}
}
state.was_active.set(active);
let progress_state = state.progress.borrow().state();
let p = progress_state.value().max(0.0);
let Some(shown) = *state.shown.borrow() else {
return;
};
if p <= 0.001 {
if !active {
state.shown.replace(None);
}
return;
}
let pose = loupe_pose(
p,
if active {
LoupePhase::Birth
} else {
LoupePhase::Collapse
},
);
let optic = loupe_optical_activity(p);
{
let mut follow_anim = state.follow_x.borrow_mut();
if !follow_anim.state().value().is_finite() {
follow_anim.snapTo(shown.focus_x);
} else if (follow_anim.target() - shown.focus_x).abs() > f32::EPSILON {
let velocity = follow_anim.velocity();
follow_anim.animate_to_with_velocity(shown.focus_x, velocity, spring(1.0, 1050.0));
}
}
let follow = state.follow_x.borrow().state().value();
let trail = shown.focus_x - follow;
let stretch = 1.0 + (trail.abs() * 0.004).clamp(0.0, 0.12);
let width = LOUPE_WIDTH * pose.width_frac * stretch;
let height = LOUPE_HEIGHT * pose.height_frac / stretch;
let center_x = follow;
let center_y = shown.line_mid_y - LOUPE_RISE * pose.rise_frac;
let focus_offset_y = shown.line_mid_y - center_y;
let corner_radius = 0.5 * width.min(height);
let spec = LiquidLoupeSpec {
magnification: LOUPE_MAGNIFICATION,
focus_offset: (0.0, focus_offset_y),
corner_radius,
activity: optic,
..LiquidLoupeSpec::default()
};
let anchor = Rect {
x: center_x - width * 0.5,
y: center_y - height * 0.5,
width: 0.0,
height: 0.0,
};
Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
let spec = spec.clone();
Box(
Modifier::empty()
.size(Size { width, height })
.graphics_layer(move || GraphicsLayer {
backdrop_effect: Some(liquid_loupe_effect((width, height), &spec)),
shape: LayerShape::Rounded(RoundedCornerShape::uniform(corner_radius)),
clip: true,
..Default::default()
}),
BoxSpec::default(),
|| {},
);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loupe_rises_for_every_handle_interaction() {
let line_bottom = 100.0;
let line_height = 20.0;
let on_line = loupe_target_for_drag(Point { x: 40.0, y: 95.0 }, line_bottom, line_height)
.expect("a finger on the line raises the loupe");
assert_eq!(on_line.focus_x, 40.0);
assert_eq!(on_line.line_mid_y, 90.0);
let on_dot = loupe_target_for_drag(Point { x: 40.0, y: 106.0 }, line_bottom, line_height)
.expect("a dot grab raises the loupe too");
assert_eq!(on_dot.line_mid_y, 90.0);
assert!(
loupe_target_for_drag(Point { x: 40.0, y: 70.0 }, line_bottom, line_height).is_some()
);
}
#[test]
fn growth_starts_at_the_handle_as_a_vertical_capsule() {
assert_eq!(
loupe_pose(0.0, LoupePhase::Birth),
LoupePose {
width_frac: 0.0,
height_frac: 0.0,
rise_frac: 0.0,
}
);
let emerging = loupe_pose(0.20, LoupePhase::Birth);
let width = LOUPE_WIDTH * emerging.width_frac;
let height = LOUPE_HEIGHT * emerging.height_frac;
assert!(
height > width,
"birth must be vertically elongated: {emerging:?}"
);
assert!(emerging.rise_frac < 0.5);
let settled = loupe_pose(1.0, LoupePhase::Birth);
assert_eq!(settled.width_frac, 1.0);
assert_eq!(settled.height_frac, 1.0);
assert_eq!(settled.rise_frac, 1.0);
}
#[test]
fn width_can_overshoot_without_inflating_height_or_rise() {
let pose = loupe_pose(1.04, LoupePhase::Birth);
assert!((pose.width_frac - 1.04).abs() < 1.0e-6);
assert!((pose.height_frac - 1.0).abs() < 1e-6);
assert!((pose.rise_frac - 1.0).abs() < 1e-6);
}
#[test]
fn grow_carries_energy_and_release_uses_the_measured_clock() {
let AnimationType::Spring(grow) = loupe_grow_spring() else {
panic!("loupe grow must use a spring");
};
assert!(grow.damping_ratio < 1.0, "birth must carry visible energy");
let AnimationType::Tween(collapse) = loupe_collapse_tween() else {
panic!("loupe collapse must use the measured linear clock");
};
assert_eq!(collapse.duration_millis, LOUPE_COLLAPSE_MS);
}
#[test]
fn shell_and_optics_share_one_continuous_progress() {
let early = loupe_pose(0.10, LoupePhase::Birth);
let middle = loupe_pose(0.50, LoupePhase::Birth);
let late = loupe_pose(0.90, LoupePhase::Birth);
assert!(early.width_frac < middle.width_frac && middle.width_frac < late.width_frac);
assert!(early.height_frac < middle.height_frac && middle.height_frac < late.height_frac);
assert!(early.rise_frac < middle.rise_frac && middle.rise_frac < late.rise_frac);
assert!(loupe_optical_activity(0.10) < loupe_optical_activity(0.50));
assert!(loupe_optical_activity(0.50) < loupe_optical_activity(0.90));
assert_eq!(loupe_optical_activity(1.0), 1.0);
}
#[test]
fn loupe_effect_relaxes_optics_without_enabling_backdrop_blur() {
let relaxed = LiquidLoupeSpec {
activity: 0.65,
..LiquidLoupeSpec::default()
};
let effect = liquid_loupe_effect((LOUPE_WIDTH, LOUPE_HEIGHT), &relaxed);
let cranpose_ui_graphics::RenderEffect::Shader { shader } = effect else {
panic!("loupe must be a bare shader effect");
};
let u = shader.uniforms();
assert!((u[9] - 0.34 * relaxed.activity).abs() < 1e-6);
assert!((u[83] - (1.0 + (LOUPE_MAGNIFICATION - 1.0) * relaxed.activity)).abs() < 1e-6);
assert!(
(u[cranpose_ui_graphics::GLASS_DISPERSION_UNIFORM]
- relaxed.dispersion * relaxed.activity)
.abs()
< 1e-6
);
assert!((u[11] - relaxed.highlight * relaxed.activity).abs() < 1e-6);
assert_eq!(u[28], relaxed.activity);
assert_eq!(u[90], relaxed.activity);
assert_eq!(u[cranpose_ui_graphics::GLASS_BLUR_RADIUS_UNIFORM], 0.0);
let grown = LiquidLoupeSpec::default();
let effect = liquid_loupe_effect((LOUPE_WIDTH, LOUPE_HEIGHT), &grown);
let cranpose_ui_graphics::RenderEffect::Shader { shader } = effect else {
panic!("loupe must be a bare shader effect");
};
let u = shader.uniforms();
assert_eq!(u[80], 1.0, "loupe mode on");
assert!(
(u[83] - LOUPE_MAGNIFICATION).abs() < 1e-6,
"full magnification"
);
assert_eq!(u[81], 0.0, "focus x on the bubble center");
assert!((u[82] - 75.0).abs() < 1e-6, "focus 75dp below the center");
assert_eq!(
&u[0..2],
&[LOUPE_WIDTH, LOUPE_HEIGHT],
"container = node dp"
);
assert_eq!(u[6], -1.0, "capsule sentinel");
assert!(
shader.input_padding() >= 75.0,
"capture must cover the offset focus, got {}",
shader.input_padding()
);
}
}