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