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}