Skip to main content

cranpose_liquid/widgets/
toggle.rs

1//! The iOS 26 switch: a 63×28 capsule track with a wide capsule thumb
2//! (~60% of the track). Pressing lifts the whole thumb into a transparent
3//! refractive glass capsule that grows past the track edges (the reference
4//! "toggle in action" frames): the track color refracts through it with a
5//! rainbow rim while the white thumb dissolves into the glass. Releasing
6//! lets the lens shrink back slowly; the white thumb rematerializes at the
7//! end of the settle. The thumb is swipable; a tap flips.
8
9use crate::material::{
10    Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape,
11};
12use crate::motion::LiquidMotion;
13use crate::theme::liquid_colors;
14use cranpose_animation::{
15    animateColorAsState, animateFloatAsState, spring, AnimationSpec, AnimationType, Easing,
16};
17use cranpose_core::{mutableStateOf, remember};
18use cranpose_macros::composable;
19use cranpose_services::{default_haptics, HapticFeedback};
20use cranpose_ui::widgets::{Box, BoxSpec};
21use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, Size};
22use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer};
23
24pub(crate) const TRACK_WIDTH: f32 = 63.0;
25pub(crate) const TRACK_HEIGHT: f32 = 28.0;
26const THUMB_WIDTH: f32 = 37.0;
27const THUMB_HEIGHT: f32 = 25.0;
28const THUMB_MARGIN: f32 = 1.5;
29/// The pressed dome anchored to the 63dp track (user-corrected twice):
30/// the chromatic ring spans ~44 x 27dp (T133/T266 at 6.27 px/dp) — a
31/// BIG dome leaning toward the travel side with the gray sticking out
32/// its trailing edge. The "thinner" read is OPTICAL: the rim band
33/// compresses the transmitted content inward so the gray under the
34/// glass slims, while the dome itself stays grand. A small dome was the
35/// wrong fix; a spilling halo was the original defect.
36/// A WIDE GRAND capsule, not a round dome: the reference hold silhouette
37/// is ~1.5 wider than tall (interior aspect 1.41, rim ring 1.78 measured
38/// on the hold tile). The dome stays grand while the OPTICS thin the gray
39/// content inside it (user: "the dome itself stays grand"); a 44dp width
40/// read too circular against the 36dp height.
41const LENS_WIDTH: f32 = 54.0;
42/// TALLER than the 28dp track: the pressed dome pokes ~4dp past both bar
43/// edges (user: "the glass bubble should be wider in height than the
44/// toggle itself").
45const LENS_HEIGHT: f32 = 36.0;
46/// Centered on the track so the dome pokes symmetrically past the top
47/// and bottom edges.
48const LENS_VERTICAL_OFFSET: f32 = 0.0;
49/// The raised lens leans toward the travel side, measured from the thumb
50/// center on the reference press and settle frames (~6-8dp in every phase:
51/// press leans toward the destination, flight leads the thumb, settle
52/// overhangs the arrival end).
53const LENS_TRAVEL_LEAN: f32 = 7.0;
54/// Glass node span beyond the lens shape (rim glow + wobble live here).
55/// The full dome plus the travel lean needs real headroom — 10dp cut the
56/// leaning edge flat (live report).
57const LENS_PAD: f32 = 18.0;
58/// Pointer travel below this is a tap, not a swipe.
59const TAP_SLOP: f32 = 4.0;
60// The flight itself now carries the raised lens; the reference keeps the
61// lens clearly readable ~750 ms after release (toggle-press sheet still
62// shows it at 883 ms) before the thumb rematerializes.
63const LENS_RELEASE_LINGER_MS: u64 = 400;
64const LENS_RELEASE_FADE_MS: u64 = 400;
65
66fn toggle_track_motion() -> AnimationType {
67    // The reference track crosses into sage ~17 ms after release and reaches
68    // full green ~150 ms later (toggle-press sheet, 60 fps): a fast-start
69    // sweep with no delay. EaseOut at 150 front-loaded the change and read
70    // fully green ~70 ms in (sheet: ours full at 231 ms vs target 283-300);
71    // 260 keeps the instant sage onset while the tail spans the reference's
72    // ~150 ms of visible progression.
73    AnimationType::Tween(AnimationSpec::tween(260, Easing::EaseOut))
74}
75
76fn toggle_lens_material() -> Glass {
77    // ONE continuous light path (example/shaders.txt wcKSRD etalon): a
78    // single `sin(pow(clamp(-sdf/refraction), 0.25) * 1.57)` displacement
79    // field, blurred, with the etalon's border + gradient lighting. The
80    // ONLY honest chromatic addition is tracing that SAME field per RGB
81    // channel at its own refractive index (dispersion) — real material
82    // aberration, not a separate spectral band. No fold crutch, no zoom
83    // chamber, no meniscus/absorption overrides, no leading-edge content
84    // gather: those layered "cratches" read as cheated compound parts.
85    // The reference press look is this etalon field over the pressed
86    // content (white-washed face + gray blob).
87    Glass::lens()
88        .shape(LiquidShape::Capsule)
89        .tint(cranpose_ui_graphics::Color::WHITE.with_alpha(0.02))
90        .blur_radius(0.8)
91        .saturation(1.0)
92        .refraction_depth(0.55)
93        .refraction_curve(0.25)
94        .transmission_refraction(1.0)
95        .dispersion(0.9)
96        .highlight(0.04)
97        .lift(0.16)
98        .shadow_style(GlassShadow::new(
99            cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
100            14.0,
101            4.0,
102            -1.5,
103        ))
104        .no_clip()
105}
106
107fn toggle_motion_bulge(pose: crate::dynamics::LiquidPose) -> f32 {
108    pose.bulge_amplitude.max(3.5 * pose.energy()).min(5.0)
109}
110
111fn toggle_lens_release() -> AnimationType {
112    AnimationType::Tween(
113        AnimationSpec::tween(LENS_RELEASE_FADE_MS, Easing::EaseIn)
114            .with_delay(LENS_RELEASE_LINGER_MS),
115    )
116}
117
118fn track_tint_progress(progress: f32) -> f32 {
119    // Direct contact never paints the committed endpoint color. The whole
120    // fixed capsule moves through gray/sage together; release owns the final
121    // transition into system green.
122    let t = ((progress.clamp(0.0, 1.0) - 0.20) / 1.25).clamp(0.0, 1.0);
123    t * t * (3.0 - 2.0 * t)
124}
125
126fn interpolate_track_color(
127    source: cranpose_ui_graphics::Color,
128    target: cranpose_ui_graphics::Color,
129    progress: f32,
130) -> cranpose_ui_graphics::Color {
131    let progress = progress.clamp(0.0, 1.0);
132    cranpose_ui_graphics::Color::rgba(
133        source.r() + (target.r() - source.r()) * progress,
134        source.g() + (target.g() - source.g()) * progress,
135        source.b() + (target.b() - source.b()) * progress,
136        source.a() + (target.a() - source.a()) * progress,
137    )
138}
139
140fn lens_translation_x(thumb_x: f32, node_width: f32) -> f32 {
141    thumb_x + (THUMB_WIDTH - node_width) * 0.5
142}
143
144/// The reference track is a recessed WELL, not a flat fill (rest frame
145/// f_001, center column at 3x): a cool-bright blue-tinted edge fading over
146/// ~2.5dp at the top, the base face, and a warm bright lip peaking ~1.5dp
147/// above the bottom edge with a neutral seam under it. These chromatic
148/// edges are exactly what the pressed dome's rim band re-images into its
149/// blue top / orange bottom arcs — the flat fill starved the ring.
150fn track_well_brush(track: cranpose_ui_graphics::Color) -> Brush {
151    let scale = |c: cranpose_ui_graphics::Color, r: f32, g: f32, b: f32| {
152        cranpose_ui_graphics::Color::rgba(
153            (c.r() * r).min(1.0),
154            (c.g() * g).min(1.0),
155            (c.b() * b).min(1.0),
156            c.a(),
157        )
158    };
159    // A whisper of a recess, not a lit lip: the strong bottom lip
160    // (1.46x) re-imaged by the dome as a weird bright highlight. The
161    // reference well is nearly flat with only a faint cool top edge.
162    let cool_top = scale(track, 1.05, 1.07, 1.09);
163    let lip = scale(track, 1.08, 1.07, 1.04);
164    let seam = scale(track, 1.02, 1.02, 1.0);
165    Brush::vertical_gradient_stops(
166        vec![
167            (0.0, cool_top),
168            (0.09, track),
169            (0.86, track),
170            (0.945, lip),
171            (1.0, seam),
172        ],
173        0.0,
174        TRACK_HEIGHT,
175        cranpose_ui_graphics::TileMode::Clamp,
176    )
177}
178
179/// The lean's travel side for a fresh press: the only end this switch can
180/// head to. Movement and release retarget it through the gesture handler —
181/// the fluid motion axis is unusable here because a slow drag stays under
182/// its direction threshold and holds whatever the previous flight left.
183fn lens_press_travel(checked: bool) -> f32 {
184    if checked {
185        -1.0
186    } else {
187        1.0
188    }
189}
190
191fn lens_ride_x(drag_progress: Option<f32>, thumb_x: f32) -> f32 {
192    drag_progress
193        .map(|progress| {
194            let min_x = THUMB_MARGIN;
195            let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
196            min_x + (max_x - min_x) * progress.clamp(0.0, 1.0)
197        })
198        .unwrap_or(thumb_x)
199}
200
201/// An on/off switch. `checked` is owned by the caller; `on_change` receives
202/// the requested new value. The thumb both taps and swipes.
203#[composable]
204#[allow(non_snake_case)]
205pub fn LiquidToggle(modifier: Modifier, checked: bool, on_change: impl Fn(bool) + 'static) {
206    let colors = liquid_colors();
207
208    // Some(progress 0..1) while the finger drags the thumb.
209    let drag_progress = remember(|| mutableStateOf(Option::<f32>::None)).with(|s| *s);
210    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
211    // ±1: which end the gesture is heading to. Press aims at the flip side,
212    // movement follows the finger, release holds the committed side through
213    // the linger (the settled lens keeps overhanging its arrival end).
214    let travel_dir = remember(|| mutableStateOf(1.0f32)).with(|s| *s);
215    let off_track = colors.toggle_off;
216    // Mid-drag the complete track interpolates with the finger. Its capsule
217    // geometry is fixed; the glass is a separate optical layer above it.
218    let base_track = match drag_progress.get() {
219        Some(progress) => {
220            interpolate_track_color(off_track, colors.toggle_on, track_tint_progress(progress))
221        }
222        None => {
223            if checked {
224                colors.toggle_on
225            } else {
226                off_track
227            }
228        }
229    };
230    // The uncovered tail retains the system accent. The lens itself creates
231    // the lighter, lower-saturation interior through refraction; applying
232    // that tone transform to the whole track washes out the reference color.
233    let animated_track = animateColorAsState(base_track, toggle_track_motion(), "toggle-track");
234    let track_color = if drag_progress.get().is_some() {
235        base_track
236    } else {
237        animated_track.get()
238    };
239
240    let min_x = THUMB_MARGIN;
241    let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
242
243    // While dragging, the spring target follows the finger: the thumb trails
244    // it with a droplet lag; on release it continues into the settle from the
245    // same spring state, velocity preserved.
246    let target_x = match drag_progress.get() {
247        Some(progress) => min_x + (max_x - min_x) * progress,
248        None => {
249            if checked {
250                max_x
251            } else {
252                min_x
253            }
254        }
255    };
256    let thumb_x = animateFloatAsState(target_x, LiquidMotion::snappy(), "toggle-thumb-x");
257    let lens_axis = crate::motion::remember_liquid_drag_axis(target_x);
258    lens_axis.settle_to(target_x, LiquidMotion::snappy());
259    let lens_x = lens_axis.value();
260
261    // Lens presence: springs to 1 fast on press (the glass materializes in
262    // ~120ms), decays slowly after release (the reference lens lingers
263    // through the settle flight for ~0.6s before the white thumb returns).
264    // A quick TAP holds the lens raised through the whole flip flight —
265    // the reference shows the full glass riding the thumb to the far end,
266    // not a faint trace of a barely-risen press.
267    let thumb_in_flight = (thumb_x.get() - target_x).abs() > 1.5;
268    let lens_target = if pressed.get() || thumb_in_flight {
269        1.0
270    } else {
271        0.0
272    };
273    let lens_progress = animateFloatAsState(
274        lens_target,
275        if pressed.get() || thumb_in_flight {
276            spring(0.9, 1400.0)
277        } else {
278            toggle_lens_release()
279        },
280        "toggle-lens",
281    );
282    // Dome press physics: the held dome is squashed deep (wide vivid rim
283    // band — the reference gray-hold rainbow); on release it relaxes to a
284    // shallow bead over the flight (the reference's thin settled ring with
285    // faint sparks). Raised-ness (lens_progress) and press depth are
286    // distinct: the lens stays raised through the whole flight while its
287    // optics relax the moment the finger lifts.
288    let press_depth = animateFloatAsState(
289        if pressed.get() { 1.0 } else { 0.45 },
290        AnimationType::Tween(AnimationSpec::tween(120, Easing::EaseOut)),
291        "toggle-press-depth",
292    );
293
294    let on_change = std::rc::Rc::new(on_change);
295    let track = Modifier::empty()
296        .size(Size::new(TRACK_WIDTH, TRACK_HEIGHT))
297        // The controlled value is part of the gesture identity. Once a
298        // release commits a new value, the next gesture must capture that
299        // value; keeping one coroutine under a constant key leaves `checked`
300        // frozen at the first composition and prevents reversing the switch.
301        .pointer_input(checked, {
302            let on_change = std::rc::Rc::clone(&on_change);
303            let lens_axis = std::rc::Rc::clone(&lens_axis);
304            move |scope: PointerInputScope| {
305                let on_change = std::rc::Rc::clone(&on_change);
306                let lens_axis = std::rc::Rc::clone(&lens_axis);
307                async move {
308                    scope
309                        .await_pointer_event_scope(|await_scope| async move {
310                            let mut down_x = 0.0f32;
311                            let mut grab_offset = 0.0f32;
312                            let mut dragging = false;
313                            loop {
314                                let event = await_scope.await_pointer_event().await;
315                                match event.kind {
316                                    PointerEventKind::Down => {
317                                        dragging = true;
318                                        down_x = event.position.x;
319                                        // The finger grabs the thumb WHERE it
320                                        // touched it: without this offset the
321                                        // first move snaps the thumb center
322                                        // under the finger (live report).
323                                        grab_offset =
324                                            event.position.x - (thumb_x.get() + THUMB_WIDTH * 0.5);
325                                        lens_axis.begin(thumb_x.get(), event.time_ms);
326                                        pressed.set(true);
327                                        travel_dir.set(lens_press_travel(checked));
328                                        default_haptics().perform(HapticFeedback::Selection);
329                                        event.consume();
330                                    }
331                                    PointerEventKind::Move if dragging => {
332                                        let progress = ((event.position.x
333                                            - grab_offset
334                                            - THUMB_MARGIN
335                                            - THUMB_WIDTH * 0.5)
336                                            / (TRACK_WIDTH - 2.0 * THUMB_MARGIN - THUMB_WIDTH))
337                                            .clamp(0.0, 1.0);
338                                        if let Some(previous) = drag_progress.get() {
339                                            if (progress - previous).abs() > 0.005 {
340                                                travel_dir.set((progress - previous).signum());
341                                            }
342                                        }
343                                        drag_progress.set(Some(progress));
344                                        lens_axis.move_to(
345                                            lens_ride_x(Some(progress), thumb_x.get()),
346                                            event.time_ms,
347                                        );
348                                        event.consume();
349                                    }
350                                    PointerEventKind::Up if dragging => {
351                                        dragging = false;
352                                        pressed.set(false);
353                                        let travelled =
354                                            (event.position.x - down_x).abs() > TAP_SLOP;
355                                        let next = if travelled {
356                                            drag_progress
357                                                .get()
358                                                .map(|p| p >= 0.5)
359                                                .unwrap_or(!checked)
360                                        } else {
361                                            !checked
362                                        };
363                                        travel_dir.set(if next { 1.0 } else { -1.0 });
364                                        lens_axis.release_to(
365                                            if next { max_x } else { min_x },
366                                            event.time_ms,
367                                            LiquidMotion::glide(),
368                                        );
369                                        drag_progress.set(None);
370                                        if next != checked {
371                                            default_haptics().perform(HapticFeedback::ImpactLight);
372                                            on_change(next);
373                                        }
374                                        event.consume();
375                                    }
376                                    PointerEventKind::Cancel if dragging => {
377                                        dragging = false;
378                                        pressed.set(false);
379                                        travel_dir.set(if checked { 1.0 } else { -1.0 });
380                                        lens_axis.release_to(
381                                            if checked { max_x } else { min_x },
382                                            event.time_ms,
383                                            LiquidMotion::snappy(),
384                                        );
385                                        drag_progress.set(None);
386                                        event.consume();
387                                    }
388                                    _ => {}
389                                }
390                            }
391                        })
392                        .await;
393                }
394            }
395        })
396        .draw_behind(move |scope| {
397            // The track is a solid recessed well that only INTERPOLATES its
398            // color (gray -> green). It must never carry moving content: a
399            // press-time white wash + a gray blob made the backdrop slide
400            // with the dome during a drag instead of just changing color.
401            scope.draw_round_rect(
402                track_well_brush(track_color),
403                CornerRadii::uniform(TRACK_HEIGHT * 0.5),
404            );
405        });
406
407    Box(track.then(modifier), BoxSpec::default(), move || {
408        let thumb_x_for_layer = thumb_x;
409        let lens_for_thumb = lens_progress;
410        // Resting thumb: a plain white capsule. It dissolves into the glass
411        // as the lens rises and rematerializes near the settle's end, so
412        // while the dome rides there is only the uniform track color beneath
413        // it — the refraction distorts nothing that reads as sliding
414        // background.
415        let thumb = Modifier::empty()
416            .size(Size::new(THUMB_WIDTH, THUMB_HEIGHT))
417            .offset(0.0, (TRACK_HEIGHT - THUMB_HEIGHT) * 0.5)
418            .graphics_layer(move || {
419                let lens = lens_for_thumb.get();
420                let alpha = ((0.30 - lens) / 0.22).clamp(0.0, 1.0);
421                GraphicsLayer {
422                    translation_x: thumb_x_for_layer.get(),
423                    alpha,
424                    ..Default::default()
425                }
426            })
427            // A soft whisper lifting the thumb off the track (the reference
428            // thumb floats; nothing dark).
429            .drop_shadow(
430                cranpose_ui_graphics::LayerShape::Rounded(
431                    cranpose_ui_graphics::RoundedCornerShape::uniform(THUMB_HEIGHT * 0.5),
432                ),
433                |scope| {
434                    scope.radius = 2.0;
435                    scope.offset.y = 0.5;
436                    scope.color = cranpose_ui_graphics::Color::BLACK.with_alpha(0.10);
437                },
438            )
439            .draw_behind(move |scope| {
440                scope.draw_round_rect(
441                    Brush::solid(cranpose_ui_graphics::Color::WHITE),
442                    CornerRadii::uniform(THUMB_HEIGHT * 0.5),
443                );
444            });
445        Box(thumb, BoxSpec::default(), || {});
446
447        // The interaction lens: one glass node riding the thumb; its SDF
448        // capsule inflates from thumb-size to the full lens with a viscous
449        // bulge along the drag direction. The morph (not a layer scale)
450        // grows it so refraction, rim and wobble stay physically coherent.
451        let deformation_headroom =
452            crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
453        let node_w =
454            LENS_WIDTH * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
455        let node_h =
456            LENS_HEIGHT * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
457        let lens_for_layer = lens_progress;
458        let physics_axis = std::rc::Rc::clone(&lens_axis);
459        let lens = Modifier::empty()
460            // required_size: the lens node MUST exceed the 63×28 track
461            // box — plain size() would be coerced by the parent's
462            // constraints, clamping the SDF and slicing the lens.
463            .required_size(Size::new(node_w, node_h))
464            .offset(0.0, (TRACK_HEIGHT - node_h) * 0.5 + LENS_VERTICAL_OFFSET)
465            .graphics_layer(move || GraphicsLayer {
466                translation_x: lens_translation_x(lens_x, node_w),
467                ..Default::default()
468            })
469            .glass_effect_with(toggle_lens_material(), move || {
470                let grow = lens_for_layer.get().clamp(0.0, 1.2);
471                let base_w = THUMB_WIDTH + (LENS_WIDTH - THUMB_WIDTH) * grow;
472                let base_h = THUMB_HEIGHT + (LENS_HEIGHT - THUMB_HEIGHT) * grow;
473                // Droplet law over the ride position: drag speed
474                // stretches the lens along the track, braking swells
475                // its leading edge (crate::dynamics).
476                let pose = physics_axis.liquid_pose();
477                // The lens leans toward its travel side in every raised
478                // phase (the reference press leans toward the destination,
479                // the settle overhangs the arrival end). The lean lives in
480                // the SDF center so the optics tilt while the node holds.
481                let lean = travel_dir.get() * LENS_TRAVEL_LEAN * grow.clamp(0.0, 1.0);
482                GlassDynamics {
483                    activity: Some(grow.clamp(0.0, 1.0)),
484                    press_depth: Some(press_depth.get()),
485                    morph: Some(GlassMorph {
486                        node_size: (node_w, node_h),
487                        primary: (node_w * 0.5 + lean, node_h * 0.5, base_w, base_h, -1.0),
488                        shapes: Vec::new(),
489                        glue: 0.0,
490                        wobble_amplitude: 0.0,
491                        wobble_phase: 0.0,
492                        bulge_amplitude: toggle_motion_bulge(pose),
493                        bulge_direction: pose.bulge_direction,
494                        ellipse_blend: 0.0,
495                        deformation: Some(pose.deformation()),
496                        // The magnification stays anchored on the thumb the
497                        // lens rides: the leaning silhouette must not drag
498                        // the optical axis toward the empty well (the white
499                        // bloom on the trailing face).
500                        zoom_anchor: (-lean, 0.0),
501                    }),
502                    ..Default::default()
503                }
504            });
505        Box(lens, BoxSpec::default(), || {});
506    });
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn toggle_geometry_matches_the_reference_proportions() {
515        assert_eq!((TRACK_WIDTH, TRACK_HEIGHT), (63.0, 28.0));
516        assert_eq!((THUMB_WIDTH, THUMB_HEIGHT), (37.0, 25.0));
517        // Reference hold (6.27 px/dp): ring ~44 x 27dp — a big dome,
518        // wider than the thumb (the gray sticks out via the LEAN), still
519        // inside the track vertically.
520        assert_eq!((LENS_WIDTH, LENS_HEIGHT), (54.0, 36.0));
521        const { assert!(LENS_WIDTH > THUMB_WIDTH) };
522        const { assert!(LENS_HEIGHT > TRACK_HEIGHT) };
523        assert_eq!(
524            toggle_lens_material().shape,
525            LiquidShape::Capsule,
526            "the pressed switch thumb remains a capsule while its optical body inflates"
527        );
528        // The dome rides high enough to expose the white face strip under
529        // its top rim (reference f_009: ~1.3dp of washed face between dome
530        // top and blob top feeds the luminous band).
531        assert!(LENS_VERTICAL_OFFSET.abs() < 1.0e-6);
532
533        let mid = interpolate_track_color(
534            cranpose_ui_graphics::Color::from_rgb_u8(187, 186, 188),
535            cranpose_ui_graphics::Color::from_rgb_u8(43, 189, 76),
536            0.5,
537        );
538        assert!((mid.r() - 115.0 / 255.0).abs() < 1.0e-6);
539        assert!((mid.g() - 187.5 / 255.0).abs() < 1.0e-6);
540        assert!((mid.b() - 132.0 / 255.0).abs() < 1.0e-6);
541    }
542
543    #[test]
544    fn toggle_lens_leans_toward_the_travel_side() {
545        // T133 detail geometry: ~12dp of gray outside the trailing rim with
546        // the 44dp dome -> the center leans ~13dp into the travel.
547        assert_eq!(LENS_TRAVEL_LEAN, 7.0);
548        // A fresh press has no motion yet: the only travel side is the
549        // opposite end of the track.
550        assert_eq!(lens_press_travel(false), 1.0);
551        assert_eq!(lens_press_travel(true), -1.0);
552
553        // Mid-drag the leaning lens frees the departed track region: its
554        // trailing edge must clear the whole-track interpolation samples
555        // (the traveling-fill contract probes 5dp in from the track end).
556        let min = THUMB_MARGIN;
557        let max = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
558        let mid_thumb_center = (min + max) * 0.5 + THUMB_WIDTH * 0.5;
559        let lens_trailing_edge = mid_thumb_center + LENS_TRAVEL_LEAN - LENS_WIDTH * 0.5;
560        assert!(lens_trailing_edge > 5.0);
561
562        // The node itself never leans — the lean lives in the SDF center so
563        // the oversized node's padding absorbs it on both sides.
564        let node_width = LENS_WIDTH
565            * crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN)
566            + crate::dynamics::BULGE_MAX
567            + LENS_PAD * 2.0;
568        let node_left = lens_translation_x(max, node_width);
569        let node_center = node_left + node_width * 0.5;
570        assert!((node_center - (max + THUMB_WIDTH * 0.5)).abs() < 1.0e-5);
571        assert!(node_width * 0.5 - LENS_WIDTH * 0.5 - LENS_TRAVEL_LEAN > 0.0);
572    }
573
574    #[test]
575    fn toggle_lens_ride_uses_pointer_progress_while_dragging() {
576        assert_eq!(lens_ride_x(Some(0.0), 20.0), THUMB_MARGIN);
577        assert_eq!(
578            lens_ride_x(Some(1.0), THUMB_MARGIN),
579            TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH
580        );
581        assert_eq!(lens_ride_x(None, 12.5), 12.5);
582    }
583
584    #[test]
585    fn toggle_track_tint_waits_for_real_travel() {
586        assert_eq!(track_tint_progress(0.20), 0.0);
587        assert!(track_tint_progress(0.35) < 0.2);
588        assert!((0.30..=0.40).contains(&track_tint_progress(0.70)));
589        assert!((0.65..=0.75).contains(&track_tint_progress(1.0)));
590    }
591
592    #[test]
593    fn toggle_track_color_sweeps_on_the_reference_clock() {
594        // Measured on the toggle-press sheet (60 fps): sage appears ~17 ms
595        // after release and the visible sweep spans ~150 ms (full green at
596        // 283-300 ms) — no delay, fast start, EaseOut tail stretched so the
597        // front-loaded curve doesn't finish the visible change in ~70 ms.
598        let AnimationType::Tween(spec) = toggle_track_motion() else {
599            panic!("toggle track color needs a bounded transition");
600        };
601        assert_eq!(spec.delay_millis, 0);
602        assert_eq!(spec.duration_millis, 260);
603        assert_eq!(spec.easing, Easing::EaseOut);
604    }
605
606    #[test]
607    fn toggle_silhouette_uses_reciprocal_shader_deformation() {
608        let pose = crate::dynamics::LiquidPose {
609            stretch: 1.25,
610            ortho: 0.8,
611            axis: (1.0, 0.0),
612            ..Default::default()
613        };
614        let deformation = pose.deformation();
615        assert_eq!(deformation.along(), 1.25);
616        assert_eq!(deformation.across(), 0.8);
617        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1e-6);
618        let cruise = crate::dynamics::LiquidPose {
619            speed: 1100.0,
620            ..Default::default()
621        };
622        assert_eq!(toggle_motion_bulge(cruise), 3.5);
623    }
624
625    #[test]
626    fn released_toggle_holds_the_full_lens_before_fading() {
627        let AnimationType::Tween(spec) = toggle_lens_release() else {
628            panic!("toggle lens release needs an explicit linger interval");
629        };
630        assert_eq!(spec.delay_millis, LENS_RELEASE_LINGER_MS);
631        assert_eq!(spec.duration_millis, LENS_RELEASE_FADE_MS);
632        assert_eq!(spec.easing, Easing::EaseIn);
633        // The reference lens stays readable ~750 ms after release
634        // (toggle-press sheet still shows it at 883 ms) before the thumb
635        // rematerializes.
636        assert!((700..=900).contains(&(spec.delay_millis + spec.duration_millis)));
637    }
638}