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::{
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
48/// Bubble size in dp (reference: 350×246 px @3x).
49pub const LOUPE_WIDTH: f32 = 117.0;
50pub const LOUPE_HEIGHT: f32 = 82.0;
51/// Bubble center height above the grabbed line's vertical mid (dp;
52/// reference: 226 px @3x).
53pub const LOUPE_RISE: f32 = 75.0;
54/// Magnification of the lens (uniform; measured on the reference).
55pub 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/// What the loupe magnifies: the finger x and the grabbed line's vertical
66/// mid, in window coordinates.
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct LoupeTarget {
69    pub focus_x: f32,
70    pub line_mid_y: f32,
71}
72
73/// The loupe rises for EVERY handle interaction — touch-down on any handle
74/// (stem, edge, or the dot hanging below the line) floats the magnifier
75/// over the dragged line. The magnified line is derived from the grabbed
76/// line, not the raw finger, so riding the dot below still focuses the
77/// line the drag manipulates.
78pub 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/// The bubble's shape/place at one instant: width and height as fractions of
91/// the full capsule, and the rise as a fraction of [`LOUPE_RISE`].
92#[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
105/// One material coordinate drives every axis. Birth gains height first so the
106/// initial shell is a narrow bulb; collapse retains more width and height until
107/// its eased drain, matching the pressure response in the recording.
108fn 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
136/// Animated loupe state living across recompositions. One progress value owns
137/// shape, rise, and optical opacity in both directions.
138struct 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/// The floating loupe. Pass the live drag target while a handle drag covers
146/// the text line, `None` otherwise; the widget runs its own grow and
147/// deflate motion (it stays mounted through the release collapse).
148#[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}