1#![allow(non_snake_case)]
26
27use std::{
28 cell::{Cell, RefCell},
29 rc::Rc,
30};
31
32use cranpose_animation::{Animatable, AnimationSpec, AnimationType, Easing, spring};
33use cranpose_core::{remember, with_current_composer};
34use cranpose_ui_graphics::{
35 GraphicsLayer, LayerShape, LiquidLoupeSpec, Point, Rect, RoundedCornerShape, Size,
36 liquid_loupe_effect,
37};
38
39use crate::{
40 composable,
41 modifier::Modifier,
42 widgets::{
43 box_widget::{Box, BoxSpec},
44 popup::Popup,
45 },
46};
47
48pub const LOUPE_WIDTH: f32 = 117.0;
50pub const LOUPE_HEIGHT: f32 = 82.0;
51pub const LOUPE_RISE: f32 = 75.0;
54pub const LOUPE_MAGNIFICATION: f32 = 1.25;
56const LOUPE_COLLAPSE_MS: u64 = 120;
57fn loupe_grow_spring() -> AnimationType {
58 spring(0.55, 320.0)
59}
60
61fn loupe_collapse_tween() -> AnimationType {
62 AnimationType::Tween(AnimationSpec::tween(LOUPE_COLLAPSE_MS, Easing::EaseInOut))
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct LoupeTarget {
69 pub focus_x: f32,
70 pub line_mid_y: f32,
71}
72
73pub fn loupe_target_for_drag(
79 finger: Point,
80 line_bottom: f32,
81 line_height: f32,
82) -> Option<LoupeTarget> {
83 let line_height = line_height.max(1.0);
84 Some(LoupeTarget {
85 focus_x: finger.x,
86 line_mid_y: line_bottom - 0.5 * line_height,
87 })
88}
89
90#[derive(Clone, Copy, Debug, PartialEq)]
93struct LoupePose {
94 width_frac: f32,
95 height_frac: f32,
96 rise_frac: f32,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100enum LoupePhase {
101 Birth,
102 Collapse,
103}
104
105fn loupe_pose(progress: f32, phase: LoupePhase) -> LoupePose {
109 let p = progress.max(0.0);
110 let bounded = p.min(1.0);
111 let (width_exponent, height_exponent, rise_exponent) = match phase {
112 LoupePhase::Birth => (0.60, 0.18, 0.60),
113 LoupePhase::Collapse => (0.80, 0.50, 1.0),
114 };
115 let width = if p <= 1.0 {
116 bounded.powf(width_exponent)
117 } else {
118 p
119 };
120 LoupePose {
121 width_frac: width,
122 height_frac: bounded.powf(height_exponent),
123 rise_frac: bounded.powf(rise_exponent),
124 }
125}
126
127fn loupe_optical_activity(progress: f32) -> f32 {
128 smoothstep01(progress)
129}
130
131fn smoothstep01(value: f32) -> f32 {
132 let t = value.clamp(0.0, 1.0);
133 t * t * (3.0 - 2.0 * t)
134}
135
136struct LoupeState {
139 progress: RefCell<Animatable<f32>>,
140 follow_x: RefCell<Animatable<f32>>,
141 shown: RefCell<Option<LoupeTarget>>,
142 was_active: Cell<bool>,
143}
144
145#[composable]
149pub fn SelectionLoupe(target: Option<LoupeTarget>) {
150 let state = remember(|| {
151 let runtime = with_current_composer(|composer| composer.runtime_handle());
152 Rc::new(LoupeState {
153 progress: RefCell::new(Animatable::new(0.0, runtime.clone())),
154 follow_x: RefCell::new(Animatable::new(f32::NAN, runtime)),
155 shown: RefCell::new(None),
156 was_active: Cell::new(false),
157 })
158 })
159 .with(Rc::clone);
160
161 let active = target.is_some();
162 if let Some(t) = target {
163 let fresh_grab = !state.was_active.get();
164 state.shown.replace(Some(t));
165 if fresh_grab {
166 let mut progress = state.progress.borrow_mut();
167 progress.snapTo(0.0);
168 progress.animateTo(1.0, loupe_grow_spring());
169 }
170 } else if state.was_active.get() {
171 let mut progress = state.progress.borrow_mut();
172 if progress.state().value() > 0.001 {
173 progress.animateTo(0.0, loupe_collapse_tween());
174 } else {
175 state.shown.replace(None);
176 }
177 }
178 state.was_active.set(active);
179
180 let progress_state = state.progress.borrow().state();
181 let p = progress_state.value().max(0.0);
182 let Some(shown) = *state.shown.borrow() else {
183 return;
184 };
185 if p <= 0.001 {
186 if !active {
187 state.shown.replace(None);
188 }
189 return;
190 }
191
192 let pose = loupe_pose(
193 p,
194 if active {
195 LoupePhase::Birth
196 } else {
197 LoupePhase::Collapse
198 },
199 );
200 let optic = loupe_optical_activity(p);
201
202 {
203 let mut follow_anim = state.follow_x.borrow_mut();
204 if !follow_anim.state().value().is_finite() {
205 follow_anim.snapTo(shown.focus_x);
206 } else if (follow_anim.target() - shown.focus_x).abs() > f32::EPSILON {
207 let velocity = follow_anim.velocity();
208 follow_anim.animate_to_with_velocity(shown.focus_x, velocity, spring(1.0, 1050.0));
209 }
210 }
211 let follow = state.follow_x.borrow().state().value();
212 let trail = shown.focus_x - follow;
213 let stretch = 1.0 + (trail.abs() * 0.004).clamp(0.0, 0.12);
214 let width = LOUPE_WIDTH * pose.width_frac * stretch;
215 let height = LOUPE_HEIGHT * pose.height_frac / stretch;
216 let center_x = follow;
217 let center_y = shown.line_mid_y - LOUPE_RISE * pose.rise_frac;
218 let focus_offset_y = shown.line_mid_y - center_y;
219
220 let corner_radius = 0.5 * width.min(height);
221 let spec = LiquidLoupeSpec {
222 magnification: LOUPE_MAGNIFICATION,
223 focus_offset: (0.0, focus_offset_y),
224 corner_radius,
225 activity: optic,
226 ..LiquidLoupeSpec::default()
227 };
228
229 let anchor = Rect {
230 x: center_x - width * 0.5,
231 y: center_y - height * 0.5,
232 width: 0.0,
233 height: 0.0,
234 };
235 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
236 let spec = spec.clone();
237 Box(
238 Modifier::empty()
239 .size(Size { width, height })
240 .graphics_layer(move || GraphicsLayer {
241 backdrop_effect: Some(liquid_loupe_effect((width, height), &spec)),
242 shape: LayerShape::Rounded(RoundedCornerShape::uniform(corner_radius)),
243 clip: true,
244 ..Default::default()
245 }),
246 BoxSpec::default(),
247 || {},
248 );
249 });
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn loupe_rises_for_every_handle_interaction() {
258 let line_bottom = 100.0;
259 let line_height = 20.0;
260 let on_line = loupe_target_for_drag(Point { x: 40.0, y: 95.0 }, line_bottom, line_height)
261 .expect("a finger on the line raises the loupe");
262 assert_eq!(on_line.focus_x, 40.0);
263 assert_eq!(on_line.line_mid_y, 90.0);
264 let on_dot = loupe_target_for_drag(Point { x: 40.0, y: 106.0 }, line_bottom, line_height)
265 .expect("a dot grab raises the loupe too");
266 assert_eq!(on_dot.line_mid_y, 90.0);
267 assert!(
268 loupe_target_for_drag(Point { x: 40.0, y: 70.0 }, line_bottom, line_height).is_some()
269 );
270 }
271
272 #[test]
273 fn growth_starts_at_the_handle_as_a_vertical_capsule() {
274 assert_eq!(
275 loupe_pose(0.0, LoupePhase::Birth),
276 LoupePose {
277 width_frac: 0.0,
278 height_frac: 0.0,
279 rise_frac: 0.0,
280 }
281 );
282 let emerging = loupe_pose(0.20, LoupePhase::Birth);
283 let width = LOUPE_WIDTH * emerging.width_frac;
284 let height = LOUPE_HEIGHT * emerging.height_frac;
285 assert!(
286 height > width,
287 "birth must be vertically elongated: {emerging:?}"
288 );
289 assert!(emerging.rise_frac < 0.5);
290
291 let settled = loupe_pose(1.0, LoupePhase::Birth);
292 assert_eq!(settled.width_frac, 1.0);
293 assert_eq!(settled.height_frac, 1.0);
294 assert_eq!(settled.rise_frac, 1.0);
295 }
296
297 #[test]
298 fn width_can_overshoot_without_inflating_height_or_rise() {
299 let pose = loupe_pose(1.04, LoupePhase::Birth);
300 assert!((pose.width_frac - 1.04).abs() < 1.0e-6);
301 assert!((pose.height_frac - 1.0).abs() < 1e-6);
302 assert!((pose.rise_frac - 1.0).abs() < 1e-6);
303 }
304
305 #[test]
306 fn grow_carries_energy_and_release_uses_the_measured_clock() {
307 let AnimationType::Spring(grow) = loupe_grow_spring() else {
308 panic!("loupe grow must use a spring");
309 };
310 assert!(grow.damping_ratio < 1.0, "birth must carry visible energy");
311 let AnimationType::Tween(collapse) = loupe_collapse_tween() else {
312 panic!("loupe collapse must use the measured linear clock");
313 };
314 assert_eq!(collapse.duration_millis, LOUPE_COLLAPSE_MS);
315 }
316
317 #[test]
318 fn shell_and_optics_share_one_continuous_progress() {
319 let early = loupe_pose(0.10, LoupePhase::Birth);
320 let middle = loupe_pose(0.50, LoupePhase::Birth);
321 let late = loupe_pose(0.90, LoupePhase::Birth);
322 assert!(early.width_frac < middle.width_frac && middle.width_frac < late.width_frac);
323 assert!(early.height_frac < middle.height_frac && middle.height_frac < late.height_frac);
324 assert!(early.rise_frac < middle.rise_frac && middle.rise_frac < late.rise_frac);
325 assert!(loupe_optical_activity(0.10) < loupe_optical_activity(0.50));
326 assert!(loupe_optical_activity(0.50) < loupe_optical_activity(0.90));
327 assert_eq!(loupe_optical_activity(1.0), 1.0);
328 }
329
330 #[test]
331 fn loupe_effect_relaxes_optics_without_enabling_backdrop_blur() {
332 let relaxed = LiquidLoupeSpec {
333 activity: 0.65,
334 ..LiquidLoupeSpec::default()
335 };
336 let effect = liquid_loupe_effect((LOUPE_WIDTH, LOUPE_HEIGHT), &relaxed);
337 let cranpose_ui_graphics::RenderEffect::Shader { shader } = effect else {
338 panic!("loupe must be a bare shader effect");
339 };
340 let u = shader.uniforms();
341 assert!((u[9] - 0.34 * relaxed.activity).abs() < 1e-6);
342 assert!((u[83] - (1.0 + (LOUPE_MAGNIFICATION - 1.0) * relaxed.activity)).abs() < 1e-6);
343 assert!(
344 (u[cranpose_ui_graphics::GLASS_DISPERSION_UNIFORM]
345 - relaxed.dispersion * relaxed.activity)
346 .abs()
347 < 1e-6
348 );
349 assert!((u[11] - relaxed.highlight * relaxed.activity).abs() < 1e-6);
350 assert_eq!(u[28], relaxed.activity);
351 assert_eq!(u[90], relaxed.activity);
352 assert_eq!(u[cranpose_ui_graphics::GLASS_BLUR_RADIUS_UNIFORM], 0.0);
353
354 let grown = LiquidLoupeSpec::default();
355 let effect = liquid_loupe_effect((LOUPE_WIDTH, LOUPE_HEIGHT), &grown);
356 let cranpose_ui_graphics::RenderEffect::Shader { shader } = effect else {
357 panic!("loupe must be a bare shader effect");
358 };
359 let u = shader.uniforms();
360 assert_eq!(u[80], 1.0, "loupe mode on");
361 assert!(
362 (u[83] - LOUPE_MAGNIFICATION).abs() < 1e-6,
363 "full magnification"
364 );
365 assert_eq!(u[81], 0.0, "focus x on the bubble center");
366 assert!((u[82] - 75.0).abs() < 1e-6, "focus 75dp below the center");
367 assert_eq!(
368 &u[0..2],
369 &[LOUPE_WIDTH, LOUPE_HEIGHT],
370 "container = node dp"
371 );
372 assert_eq!(u[6], -1.0, "capsule sentinel");
373 assert!(
374 shader.input_padding() >= 75.0,
375 "capture must cover the offset focus, got {}",
376 shader.input_padding()
377 );
378 }
379}