Skip to main content

frus_widgets/
overscroll.rs

1//! The **overscroll glow**: the arc of light that swells at the edge of a
2//! scrollable when the content will not go any further.
3//!
4//! A platform that bounces answers "there is nothing more" with movement — the
5//! content pulls away and springs back, and the user needs no other signal. A
6//! platform that clamps refuses the movement outright, so without something else
7//! the edge is simply **dead**: the finger drags and nothing at all happens, which
8//! reads as a frozen app rather than as the end of a list.
9//!
10//! The glow is that something else. It is the counterpart of the bounce, and the
11//! reason [`ScrollPhysics::CLAMPING`](crate::physics::ScrollPhysics) was only half a
12//! behaviour until now.
13//!
14//! It answers two different events, and they look different on purpose:
15//!
16//! - [`OverscrollGlow::absorb_impact`] — a **fling** hit the edge. A quick, bright
17//!   flash whose strength comes from the speed that was absorbed: the faster the
18//!   content was going, the harder it appears to have landed.
19//! - [`OverscrollGlow::pull`] — a **finger** is dragging past the edge. The glow
20//!   grows with the accumulated pull, holds while the finger stays, and decays
21//!   slowly if it lingers.
22//!
23//! Both then recede. Everything is a pure function of elapsed time — the shell
24//! feeds events in and calls [`OverscrollGlow::advance`] once a frame — so the whole
25//! state machine is testable with no window and no GPU.
26
27use frus_core::{Color, Curve, Path, Point, Rect, Scene, Size};
28
29/// Which edge of a viewport a glow sits on.
30#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
31pub enum GlowEdge {
32    /// The top edge.
33    Top,
34    /// The bottom edge.
35    Bottom,
36    /// The left edge.
37    Left,
38    /// The right edge.
39    Right,
40}
41
42impl GlowEdge {
43    /// The four edges, in the order [`ScrollGlows`] stores them.
44    pub const ALL: [GlowEdge; 4] = [
45        GlowEdge::Top,
46        GlowEdge::Bottom,
47        GlowEdge::Left,
48        GlowEdge::Right,
49    ];
50
51    fn index(self) -> usize {
52        match self {
53            GlowEdge::Top => 0,
54            GlowEdge::Bottom => 1,
55            GlowEdge::Left => 2,
56            GlowEdge::Right => 3,
57        }
58    }
59
60    /// Does this edge sit on the vertical axis?
61    fn vertical(self) -> bool {
62        matches!(self, GlowEdge::Top | GlowEdge::Bottom)
63    }
64}
65
66/// What the glow is currently doing.
67#[derive(Copy, Clone, Debug, PartialEq, Eq)]
68enum GlowState {
69    /// Nothing to draw.
70    Idle,
71    /// Flashing after a fling hit the edge.
72    Absorb,
73    /// Growing under a finger that is still pulling.
74    Pull,
75    /// Fading away.
76    Recede,
77}
78
79/// How long a glow takes to fade once the gesture is over.
80const RECEDE_TIME: f32 = 0.600;
81/// How long a pull takes to reach its new size.
82const PULL_TIME: f32 = 0.167;
83/// How long a pull is held before it starts decaying on its own.
84const PULL_HOLD_TIME: f32 = 0.167;
85/// How long that unattended decay takes — much slower than letting go.
86const PULL_DECAY_TIME: f32 = 2.000;
87/// The half-life of the sideways slide towards the finger, in seconds.
88const CROSS_AXIS_HALF_TIME: f32 = 1.0 / 60.0;
89
90/// The glow never gets more opaque than this.
91const MAX_OPACITY: f32 = 0.5;
92/// How much of a pull, relative to the viewport, turns into opacity.
93const PULL_OPACITY_GLOW_FACTOR: f32 = 0.8;
94/// How much of an absorbed speed turns into opacity.
95const VELOCITY_GLOW_FACTOR: f32 = 0.000_06;
96/// The arc's height as a fraction of the edge's length: `¾·(2 − √3)`.
97const WIDTH_TO_HEIGHT_FACTOR: f32 = 0.75 * (2.0 - 1.732_050_8);
98/// Absorbed speeds are clamped into this range, in px/s.
99const MIN_ABSORB_VELOCITY: f32 = 100.0;
100const MAX_ABSORB_VELOCITY: f32 = 10_000.0;
101
102/// The glow on one edge of one scrollable.
103///
104/// Two animated quantities — opacity and size — each interpolated from where it
105/// was to where the last event asked it to go, shaped by a decelerating curve. That
106/// "from where it was" is what keeps a second event mid-flight from making the glow
107/// jump.
108#[derive(Copy, Clone, Debug)]
109pub struct OverscrollGlow {
110    state: GlowState,
111    /// Progress of the current transition, in seconds, and how long it lasts.
112    elapsed: f32,
113    duration: f32,
114    opacity_from: f32,
115    opacity_to: f32,
116    size_from: f32,
117    size_to: f32,
118    /// Where along the edge the arc is centred, in `0..=1`, and where it is
119    /// heading — it slides towards the finger rather than jumping to it.
120    displacement: f32,
121    displacement_target: f32,
122    /// The accumulated pull, which is what makes a long drag glow more than a
123    /// short one even though each frame's overscroll is small.
124    pull_distance: f32,
125    /// Time left before an unattended pull starts to decay; `None` when nothing is
126    /// being held.
127    hold_left: Option<f32>,
128}
129
130impl Default for OverscrollGlow {
131    fn default() -> Self {
132        Self {
133            state: GlowState::Idle,
134            elapsed: 0.0,
135            duration: 0.0,
136            opacity_from: 0.0,
137            opacity_to: 0.0,
138            size_from: 0.0,
139            size_to: 0.0,
140            displacement: 0.5,
141            displacement_target: 0.5,
142            pull_distance: 0.0,
143            hold_left: None,
144        }
145    }
146}
147
148impl OverscrollGlow {
149    /// Progress through the current transition, shaped by the decelerating curve.
150    fn t(&self) -> f32 {
151        if self.duration <= 0.0 {
152            return 1.0;
153        }
154        Curve::Decelerate.transform((self.elapsed / self.duration).clamp(0.0, 1.0))
155    }
156
157    /// The opacity to paint with, `0` when there is nothing to show.
158    pub fn opacity(&self) -> f32 {
159        self.opacity_from + (self.opacity_to - self.opacity_from) * self.t()
160    }
161
162    /// The size factor, `0..=1`, scaling the arc's height.
163    pub fn size(&self) -> f32 {
164        self.size_from + (self.size_to - self.size_from) * self.t()
165    }
166
167    /// Is there nothing left to draw or animate?
168    pub fn is_idle(&self) -> bool {
169        self.state == GlowState::Idle
170    }
171
172    /// A fling hit this edge at `velocity` px/s (a magnitude, so positive).
173    ///
174    /// The flash is brief and its strength comes from the speed: this is the visual
175    /// consequence of the momentum the clamping physics just threw away.
176    pub fn absorb_impact(&mut self, velocity: f32) {
177        let velocity = velocity
178            .abs()
179            .clamp(MIN_ABSORB_VELOCITY, MAX_ABSORB_VELOCITY);
180        self.hold_left = None;
181        // A glow already on screen grows from where it is; a cold start begins part
182        // way up, so that even a gentle landing registers.
183        self.opacity_from = if self.state == GlowState::Idle {
184            0.3
185        } else {
186            self.opacity()
187        };
188        self.opacity_to = (velocity * VELOCITY_GLOW_FACTOR).clamp(self.opacity_from, MAX_OPACITY);
189        self.size_from = self.size();
190        self.size_to = (0.025 + 7.5e-7 * velocity * velocity).min(1.0);
191        // Milliseconds, and deliberately short: an impact is a flash, not a swell.
192        self.duration = (0.15 + velocity * 0.02) / 1000.0;
193        self.elapsed = 0.0;
194        self.displacement = 0.5;
195        self.state = GlowState::Absorb;
196    }
197
198    /// A finger is dragging `overscroll` px past this edge.
199    ///
200    /// `extent` is the viewport along the scroll axis, `cross_offset` where the
201    /// finger is along the edge and `cross_extent` the edge's length — the last two
202    /// are what make the arc appear under the thumb instead of always in the middle.
203    pub fn pull(&mut self, overscroll: f32, extent: f32, cross_offset: f32, cross_extent: f32) {
204        let extent = extent.max(1.0);
205        let cross_extent = cross_extent.max(1.0);
206        self.hold_left = Some(PULL_HOLD_TIME);
207        // The divisor is empirical: it is what makes the growth match the platform's.
208        self.pull_distance += overscroll.abs() / 200.0;
209        self.opacity_from = self.opacity();
210        self.opacity_to = (self.opacity_from
211            + overscroll.abs() / extent * PULL_OPACITY_GLOW_FACTOR)
212            .min(MAX_OPACITY);
213        let height = extent.min(cross_extent * WIDTH_TO_HEIGHT_FACTOR);
214        self.size_from = self.size();
215        // Never smaller than it already is: a pull only ever swells.
216        self.size_to =
217            (1.0 - 1.0 / (0.7 * (self.pull_distance * height).sqrt())).max(self.size_from);
218        self.displacement_target = (cross_offset / cross_extent).clamp(0.0, 1.0);
219        self.duration = PULL_TIME;
220        if self.state != GlowState::Pull {
221            self.elapsed = 0.0;
222            self.state = GlowState::Pull;
223        }
224    }
225
226    /// The gesture ended: a pull now fades at the "let go" rate.
227    pub fn scroll_end(&mut self) {
228        if self.state == GlowState::Pull {
229            self.recede(RECEDE_TIME);
230        }
231    }
232
233    /// Starts the fade.
234    fn recede(&mut self, duration: f32) {
235        if matches!(self.state, GlowState::Recede | GlowState::Idle) {
236            return;
237        }
238        self.hold_left = None;
239        self.opacity_from = self.opacity();
240        self.opacity_to = 0.0;
241        self.size_from = self.size();
242        self.size_to = 0.0;
243        self.duration = duration;
244        self.elapsed = 0.0;
245        self.state = GlowState::Recede;
246    }
247
248    /// Advances by `dt` seconds. Returns `true` while there is still something to
249    /// animate.
250    pub fn advance(&mut self, dt: f32) -> bool {
251        if self.state == GlowState::Idle {
252            return false;
253        }
254        self.elapsed += dt;
255        // The arc slides towards the finger by halving the remaining distance every
256        // `CROSS_AXIS_HALF_TIME` — frame-rate independent, unlike a fixed step.
257        if (self.displacement_target - self.displacement).abs() > 1e-3 {
258            let decay = 2f32.powf(-dt / CROSS_AXIS_HALF_TIME);
259            self.displacement =
260                self.displacement_target - (self.displacement_target - self.displacement) * decay;
261        } else {
262            self.displacement = self.displacement_target;
263        }
264        // A pull left unattended starts decaying on its own, slowly.
265        if let Some(left) = self.hold_left.as_mut() {
266            *left -= dt;
267            if *left <= 0.0 {
268                self.hold_left = None;
269                self.recede(PULL_DECAY_TIME);
270            }
271        }
272        if self.elapsed >= self.duration {
273            match self.state {
274                // A flash always fades straight afterwards.
275                GlowState::Absorb => self.recede(RECEDE_TIME),
276                GlowState::Recede => {
277                    self.state = GlowState::Idle;
278                    self.pull_distance = 0.0;
279                    self.opacity_from = 0.0;
280                    self.opacity_to = 0.0;
281                    self.size_from = 0.0;
282                    self.size_to = 0.0;
283                    return false;
284                }
285                // A pull holds at its size until the finger says otherwise.
286                GlowState::Pull | GlowState::Idle => {}
287            }
288        }
289        true
290    }
291
292    /// Paints the arc on `edge` of `viewport`.
293    ///
294    /// The shape is a wide, shallow ellipse whose visible sliver is the part inside
295    /// a thin band along the edge — the same construction on all four sides, with
296    /// the band's frame swapped and mirrored to suit.
297    pub fn paint(&self, viewport: Rect, edge: GlowEdge, color: Color, scene: &mut Scene) {
298        let opacity = self.opacity();
299        if opacity <= 0.001 || self.size() <= 0.0 {
300            return;
301        }
302        // In the edge's own frame, `along` runs the length of the edge and `into`
303        // points away from it, into the content.
304        let (along, into) = if edge.vertical() {
305            (viewport.width, viewport.height)
306        } else {
307            (viewport.height, viewport.width)
308        };
309        if along <= 0.0 || into <= 0.0 {
310            return;
311        }
312
313        // A viewport wider than it is deep would give an absurdly tall arc; the base
314        // scale keeps the glow proportional to the shallower dimension.
315        let base_scale = if along > into { into / along } else { 1.0 };
316        let radius = along * 1.5;
317        let band = into.min(along * WIDTH_TO_HEIGHT_FACTOR);
318        let scale_y = self.size() * base_scale;
319        let center_along = (along / 2.0) * (0.5 + self.displacement);
320        // The circle sits mostly outside the viewport: only its cap shows. Scaling
321        // it towards the edge is what turns the cap into a flat, wide arc.
322        let center_into = (band - radius) * scale_y;
323        let oval = Rect::new(
324            center_along - radius,
325            center_into - radius * scale_y,
326            radius * 2.0,
327            radius * 2.0 * scale_y,
328        );
329
330        // Map the edge's frame onto the screen. Each case is a mirror, a transpose
331        // or both, so an axis-aligned ellipse stays one.
332        let map = |r: Rect| -> Rect {
333            match edge {
334                GlowEdge::Top => Rect::new(viewport.x + r.x, viewport.y + r.y, r.width, r.height),
335                GlowEdge::Bottom => Rect::new(
336                    viewport.x + r.x,
337                    viewport.y + viewport.height - r.y - r.height,
338                    r.width,
339                    r.height,
340                ),
341                GlowEdge::Left => Rect::new(viewport.x + r.y, viewport.y + r.x, r.height, r.width),
342                GlowEdge::Right => Rect::new(
343                    viewport.x + viewport.width - r.y - r.height,
344                    viewport.y + r.x,
345                    r.height,
346                    r.width,
347                ),
348            }
349        };
350
351        let band_rect = map(Rect::new(0.0, 0.0, along, band));
352        let previous = scene.current_clip();
353        scene.set_clip(previous.intersect(band_rect));
354        // Gone **by the arc's own boundary**, full strength where it meets the edge.
355        // A flat fill gives the arc a hard curved edge across the whole width, which
356        // reads as the page being bent rather than as light — found on a device
357        // (milestone 301). The fade is what makes it a glow.
358        //
359        // It has to be radial. The arc is the cap of an ellipse, so its boundary is at
360        // its deepest in the middle and rises towards the flanks; a straight fade aimed
361        // at that deepest point still has colour left where the boundary comes up to
362        // meet it, and that leftover is a visible edge at each end of the arc — the
363        // same defect one layer down, seen on the device again (milestone 302).
364        //
365        // The inner radius is where the ellipse crosses the edge of the band, so the
366        // fade begins there and not at the centre, which is far off screen. It falls
367        // out of the geometry alone: the ellipse's centre sits `radius - band` from the
368        // edge and its radius is `radius`, both scaled the same way, so `scale_y`
369        // cancels and only the ratio remains.
370        let inner = ((radius - band) / radius).clamp(0.0, 0.999);
371        let arc = map(oval);
372        scene.fill_path_radial(
373            &Path::oval(arc),
374            color.fade(opacity),
375            color.fade(0.0),
376            Point::new(arc.x + arc.width / 2.0, arc.y + arc.height / 2.0),
377            Size::new(arc.width / 2.0, arc.height / 2.0),
378            inner,
379        );
380        scene.set_clip(previous);
381    }
382}
383
384/// The four glows of one scrollable region.
385///
386/// All four are always present and cost nothing while idle, which spares every
387/// caller an `Option` and a lookup for the common case of a list that is simply
388/// being scrolled.
389#[derive(Copy, Clone, Debug, Default)]
390pub struct ScrollGlows([OverscrollGlow; 4]);
391
392impl ScrollGlows {
393    /// The glow on `edge`.
394    pub fn edge(&self, edge: GlowEdge) -> &OverscrollGlow {
395        &self.0[edge.index()]
396    }
397
398    /// The glow on `edge`, to drive.
399    pub fn edge_mut(&mut self, edge: GlowEdge) -> &mut OverscrollGlow {
400        &mut self.0[edge.index()]
401    }
402
403    /// Is every edge quiet?
404    pub fn is_idle(&self) -> bool {
405        self.0.iter().all(OverscrollGlow::is_idle)
406    }
407
408    /// Advances all four. Returns `true` while any is still animating.
409    pub fn advance(&mut self, dt: f32) -> bool {
410        let mut animating = false;
411        for glow in &mut self.0 {
412            animating |= glow.advance(dt);
413        }
414        animating
415    }
416
417    /// Tells every edge the gesture is over.
418    pub fn scroll_end(&mut self) {
419        for glow in &mut self.0 {
420            glow.scroll_end();
421        }
422    }
423
424    /// Paints all four onto `viewport`.
425    pub fn paint(&self, viewport: Rect, color: Color, scene: &mut Scene) {
426        for edge in GlowEdge::ALL {
427            self.edge(edge).paint(viewport, edge, color, scene);
428        }
429    }
430}
431
432/// The edge a scroll offset ran past, if it did: `delta` is the refused movement,
433/// negative meaning the content was pulled towards the start.
434pub fn edge_for(vertical: bool, delta: f32) -> GlowEdge {
435    match (vertical, delta < 0.0) {
436        (true, true) => GlowEdge::Top,
437        (true, false) => GlowEdge::Bottom,
438        (false, true) => GlowEdge::Left,
439        (false, false) => GlowEdge::Right,
440    }
441}
442
443/// Where a glow's arc should be centred along `edge`, given a pointer at `point`
444/// inside `viewport` — and how long that edge is.
445pub fn cross_axis(viewport: Rect, edge: GlowEdge, point: Point) -> (f32, f32) {
446    if edge.vertical() {
447        (
448            (point.x - viewport.x).clamp(0.0, viewport.width),
449            viewport.width,
450        )
451    } else {
452        (
453            (point.y - viewport.y).clamp(0.0, viewport.height),
454            viewport.height,
455        )
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn a_fresh_glow_shows_nothing() {
465        let glow = OverscrollGlow::default();
466        assert!(glow.is_idle());
467        assert_eq!(glow.opacity(), 0.0);
468        let mut glow = glow;
469        assert!(!glow.advance(0.016), "an idle glow does not animate");
470    }
471
472    #[test]
473    fn an_impact_flashes_then_fades_to_nothing() {
474        let mut glow = OverscrollGlow::default();
475        glow.absorb_impact(3000.0);
476        assert!(!glow.is_idle());
477        assert!(glow.opacity() > 0.0, "visible at once");
478        // It runs, and it ends: no glow outlives its gesture.
479        let mut frames = 0;
480        while glow.advance(1.0 / 60.0) {
481            frames += 1;
482            assert!(frames < 300, "the flash never finished");
483        }
484        assert!(glow.is_idle());
485        assert_eq!(glow.opacity(), 0.0);
486    }
487
488    #[test]
489    fn a_faster_impact_glows_harder() {
490        let peak = |velocity: f32| {
491            let mut glow = OverscrollGlow::default();
492            glow.absorb_impact(velocity);
493            let mut peak: f32 = 0.0;
494            loop {
495                peak = peak.max(glow.opacity() * glow.size());
496                if !glow.advance(1.0 / 60.0) {
497                    break;
498                }
499            }
500            peak
501        };
502        assert!(
503            peak(6000.0) > peak(600.0),
504            "the harder landing should show more"
505        );
506    }
507
508    #[test]
509    fn the_absorbed_opacity_is_capped() {
510        let mut glow = OverscrollGlow::default();
511        glow.absorb_impact(1e9); // absurd, and clamped
512        let mut peak: f32 = 0.0;
513        loop {
514            peak = peak.max(glow.opacity());
515            if !glow.advance(1.0 / 60.0) {
516                break;
517            }
518        }
519        assert!(peak <= MAX_OPACITY + 1e-4, "peak = {peak}");
520    }
521
522    #[test]
523    fn a_longer_pull_glows_more_than_a_short_one() {
524        let after = |pulls: usize| {
525            let mut glow = OverscrollGlow::default();
526            for _ in 0..pulls {
527                glow.pull(12.0, 800.0, 200.0, 400.0);
528                glow.advance(1.0 / 60.0);
529            }
530            glow.size()
531        };
532        assert!(after(20) > after(2), "the pull must accumulate");
533    }
534
535    #[test]
536    fn letting_go_of_a_pull_fades_it() {
537        let mut glow = OverscrollGlow::default();
538        glow.pull(40.0, 800.0, 200.0, 400.0);
539        glow.advance(1.0 / 60.0);
540        assert!(glow.opacity() > 0.0);
541        glow.scroll_end();
542        let mut frames = 0;
543        while glow.advance(1.0 / 60.0) {
544            frames += 1;
545            assert!(frames < 200, "the fade never finished");
546        }
547        assert!(glow.is_idle());
548    }
549
550    #[test]
551    fn a_pull_left_alone_decays_on_its_own() {
552        // The finger stopped moving but never lifted: the glow must not sit there
553        // for ever.
554        let mut glow = OverscrollGlow::default();
555        glow.pull(40.0, 800.0, 200.0, 400.0);
556        // Past the hold time, with no further pull and no release.
557        for _ in 0..20 {
558            glow.advance(1.0 / 60.0);
559        }
560        let held = glow.opacity();
561        for _ in 0..60 {
562            glow.advance(1.0 / 60.0);
563        }
564        assert!(glow.opacity() < held, "it should be fading by itself");
565    }
566
567    #[test]
568    fn the_arc_slides_towards_the_finger() {
569        let mut glow = OverscrollGlow::default();
570        // A pull at the far right of a 400 px wide edge.
571        glow.pull(20.0, 800.0, 400.0, 400.0);
572        let start = glow.displacement;
573        assert_eq!(start, 0.5, "it starts centred");
574        for _ in 0..10 {
575            glow.advance(1.0 / 60.0);
576        }
577        assert!(
578            glow.displacement > start && glow.displacement <= 1.0,
579            "displacement = {}",
580            glow.displacement
581        );
582    }
583
584    #[test]
585    fn a_glow_paints_inside_its_edge_band() {
586        let viewport = Rect::new(10.0, 20.0, 400.0, 600.0);
587        for edge in GlowEdge::ALL {
588            let mut glow = OverscrollGlow::default();
589            glow.absorb_impact(4000.0);
590            glow.advance(0.05);
591            let mut scene = Scene::new();
592            scene.set_clip(Rect::new(0.0, 0.0, 1000.0, 1000.0));
593            glow.paint(viewport, edge, Color::rgb(0.0, 1.0, 0.0), &mut scene);
594            assert_eq!(scene.len(), 1, "{edge:?} should paint exactly one shape");
595            let bounds = scene.primitives()[0].bounds();
596            // Whatever the ellipse's own size, the clip keeps it against its edge.
597            let clip = match scene.primitives()[0] {
598                frus_core::Primitive::Path { clip, .. } => clip,
599                _ => panic!("the glow is a filled path"),
600            };
601            assert!(
602                clip.width <= viewport.width + 1.0 && clip.height <= viewport.height + 1.0,
603                "{edge:?} band {clip:?} escapes the viewport"
604            );
605            let touches_edge = match edge {
606                GlowEdge::Top => (clip.y - viewport.y).abs() < 1.0,
607                GlowEdge::Bottom => {
608                    ((clip.y + clip.height) - (viewport.y + viewport.height)).abs() < 1.0
609                }
610                GlowEdge::Left => (clip.x - viewport.x).abs() < 1.0,
611                GlowEdge::Right => {
612                    ((clip.x + clip.width) - (viewport.x + viewport.width)).abs() < 1.0
613                }
614            };
615            assert!(touches_edge, "{edge:?} band {clip:?} is not on its edge");
616            let _ = bounds;
617        }
618    }
619
620    #[test]
621    fn an_idle_glow_paints_nothing() {
622        let mut scene = Scene::new();
623        OverscrollGlow::default().paint(
624            Rect::new(0.0, 0.0, 100.0, 100.0),
625            GlowEdge::Top,
626            Color::rgb(1.0, 1.0, 1.0),
627            &mut scene,
628        );
629        assert!(scene.is_empty());
630    }
631
632    #[test]
633    fn the_refused_direction_picks_the_edge() {
634        assert_eq!(edge_for(true, -5.0), GlowEdge::Top);
635        assert_eq!(edge_for(true, 5.0), GlowEdge::Bottom);
636        assert_eq!(edge_for(false, -5.0), GlowEdge::Left);
637        assert_eq!(edge_for(false, 5.0), GlowEdge::Right);
638    }
639
640    #[test]
641    fn the_cross_axis_is_measured_along_the_edge() {
642        let viewport = Rect::new(10.0, 20.0, 400.0, 600.0);
643        let (offset, extent) = cross_axis(viewport, GlowEdge::Top, Point::new(210.0, 300.0));
644        assert_eq!((offset, extent), (200.0, 400.0));
645        let (offset, extent) = cross_axis(viewport, GlowEdge::Left, Point::new(210.0, 320.0));
646        assert_eq!((offset, extent), (300.0, 600.0));
647        // A pointer outside the viewport still lands on the edge.
648        let (offset, _) = cross_axis(viewport, GlowEdge::Top, Point::new(-500.0, 0.0));
649        assert_eq!(offset, 0.0);
650    }
651
652    #[test]
653    fn all_four_edges_are_addressable_and_start_quiet() {
654        let mut glows = ScrollGlows::default();
655        assert!(glows.is_idle());
656        glows.edge_mut(GlowEdge::Bottom).absorb_impact(2000.0);
657        assert!(!glows.is_idle());
658        assert!(glows.edge(GlowEdge::Top).is_idle(), "only one edge reacts");
659        while glows.advance(1.0 / 60.0) {}
660        assert!(glows.is_idle());
661    }
662}