Skip to main content

cranpose_ui/widgets/
loupe.rs

1//! The liquid-glass text loupe: the magnifier bubble floating over a dragged
2//! caret / selection handle.
3//!
4//! Matched against the reference recording
5//! (`example/target/text-selection/`): a 117×82 dp glass capsule whose center
6//! rides [`LOUPE_RISE`] dp above the grabbed line's vertical mid. It is a
7//! pure backdrop lens — the shader magnifies the live scene (text, selection
8//! highlight, the handle itself) a uniform ~1.25×,
9//! folding into an inverted, chromatically dispersed band at the rim (see
10//! `liquid_glass.wgsl` loupe mode). The widget itself draws nothing.
11//!
12//! Motion follows `example/target/on-white/text-handle-bubble/`:
13//! * the shell starts at the touched handle as a narrow vertical capsule;
14//! * width, height, rise, refraction, magnification, chroma, and edge light
15//!   increase from one continuous progress value over roughly 200 ms;
16//! * every active frame uses the current finger x directly while the vertical
17//!   rise supplies the stable grab offset;
18//! * release retains the broad face briefly, then sinks into the handle while
19//!   the shell and its optics drain over roughly 250 ms.
20//!
21//! Visibility (also from the recording): the loupe shows only while the
22//! finger covers the text line — dragging a handle by its dot below the line
23//! rises for every handle interaction (see [`loupe_target_for_drag`]).
24
25#![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
41/// Bubble size in dp (reference: 350×246 px @3x).
42pub const LOUPE_WIDTH: f32 = 117.0;
43pub const LOUPE_HEIGHT: f32 = 82.0;
44/// Bubble center height above the grabbed line's vertical mid (dp;
45/// reference: 226 px @3x).
46pub const LOUPE_RISE: f32 = 75.0;
47/// Magnification of the lens (uniform; measured on the reference).
48pub const LOUPE_MAGNIFICATION: f32 = 1.25;
49// The reference loupe deflates into the line over ~100 ms (loupe-dissolve
50// frames b_024..b_036 at native 120 fps).
51const LOUPE_COLLAPSE_MS: u64 = 120;
52fn loupe_grow_spring() -> AnimationType {
53    // The reference birth carries ENERGY: the bubble overshoots and wobbles
54    // a beat before settling (target loupe-grow frames) — underdamped on
55    // purpose.
56    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/// What the loupe magnifies: the finger x and the grabbed line's vertical
64/// mid, in window coordinates.
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct LoupeTarget {
67    pub focus_x: f32,
68    pub line_mid_y: f32,
69}
70
71/// The loupe rises for EVERY handle interaction — touch-down on any handle
72/// (stem, edge, or the dot hanging below the line) floats the magnifier
73/// over the dragged line. The magnified line is derived from the grabbed
74/// line, not the raw finger, so riding the dot below still focuses the
75/// line the drag manipulates.
76pub 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/// The bubble's shape/place at one instant: width and height as fractions of
89/// the full capsule, and the rise as a fraction of [`LOUPE_RISE`].
90#[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
103/// One material coordinate drives every axis. Birth gains height first so the
104/// initial shell is a narrow bulb; collapse retains more width and height until
105/// its eased drain, matching the pressure response in the recording.
106fn 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
134/// Animated loupe state living across recompositions. One progress value owns
135/// shape, rise, and optical opacity in both directions.
136struct LoupeState {
137    progress: RefCell<Animatable<f32>>,
138    /// The bubble's center x trails the touch on the animation clock: a
139    /// non-oscillating spring calibrated to the reference's measured trail
140    /// (20-35px at ~356px/s => stiffness ~650 critically damped). The live
141    /// TRAIL is the drag force: it drives the droplet's stretch.
142    follow_x: RefCell<Animatable<f32>>,
143    /// The last target shown; kept while the bubble deflates after release.
144    shown: RefCell<Option<LoupeTarget>>,
145    /// Whether the loupe was active on the previous recomposition.
146    was_active: Cell<bool>,
147}
148
149/// The floating loupe. Pass the live drag target while a handle drag covers
150/// the text line, `None` otherwise; the widget runs its own grow and
151/// deflate motion (it stays mounted through the release collapse).
152#[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    // Droplet physics: the bubble CENTER trails the touch on the
207    // animation clock (non-oscillating spring; the reference measures a
208    // 20-35px trail at ~356px/s and convergence with no overshoot), and
209    // the live trail IS the drag force — it stretches the bubble along
210    // travel and contracts it orthogonally, AREA CONSERVED
211    // (w*s * h/s = w*h).
212    {
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            // Retarget on every move, carrying velocity: springs keep their
218            // frame chain across retargets, so this is a true continuous
219            // tracker (steady-state trail 2v/omega, ~22dp at the reference's
220            // 356dp/s drag).
221            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    // The lens looks at the line (the focus), which sits below the risen
233    // center.
234    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        // Finger on the line: loupe up, focused on the line mid.
277        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        // Finger on the dot below the line: the loupe STILL rises, focused
282        // on the grabbed line (any handle touch floats the magnifier).
283        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        // Finger above the line (the start handle's dot): loupe up.
287        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        // No value pins while the look is still being matched by vision —
328        // only the BEHAVIOR: the birth must be an underdamped spring (the
329        // reference bubble overshoots and wobbles a beat).
330        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        // Geometry: explicit-rect capsule covering the node, in dp.
391        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        // The capture must reach the offset focus area.
398        assert!(
399            shader.input_padding() >= 75.0,
400            "capture must cover the offset focus, got {}",
401            shader.input_padding()
402        );
403    }
404}