Skip to main content

dioxus_dnd/
autoscroll.rs

1#![doc = include_str!("../docs/api/autoscroll.md")]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use dioxus::html::geometry::PixelsVector2D;
7use dioxus::html::{MountedData, ScrollBehavior};
8use dioxus::prelude::*;
9
10use crate::core::hooks::use_rect_refresh_provider;
11use crate::core::{Point, Rect};
12
13static NEXT_SCROLL_CONTAINER: AtomicU64 = AtomicU64::new(1);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub(crate) struct ScrollContainerId(pub u64);
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19struct ScrollEntry {
20    id: ScrollContainerId,
21    rect: Rect,
22    available: bool,
23    blocked: bool,
24}
25
26/// Coordinates nested auto-scroll surfaces. The smallest available
27/// containing rect owns movement; when it reaches a boundary it marks itself
28/// blocked and the next containing surface takes over.
29pub(crate) struct ScrollCoordinator {
30    entries: Signal<Vec<ScrollEntry>>,
31}
32
33impl Copy for ScrollCoordinator {}
34impl Clone for ScrollCoordinator {
35    fn clone(&self) -> Self {
36        *self
37    }
38}
39impl PartialEq for ScrollCoordinator {
40    fn eq(&self, other: &Self) -> bool {
41        self.entries == other.entries
42    }
43}
44
45impl ScrollCoordinator {
46    fn new() -> Self {
47        Self {
48            entries: Signal::new(Vec::new()),
49        }
50    }
51
52    fn update(&mut self, id: ScrollContainerId, rect: Rect, available: bool) {
53        let mut entries = self.entries.write();
54        if let Some(entry) = entries.iter_mut().find(|entry| entry.id == id) {
55            entry.rect = rect;
56            entry.available = available;
57        } else {
58            entries.push(ScrollEntry {
59                id,
60                rect,
61                available,
62                blocked: false,
63            });
64        }
65    }
66
67    fn set_blocked(&mut self, id: ScrollContainerId, blocked: bool) {
68        if let Some(entry) = self.entries.write().iter_mut().find(|entry| entry.id == id) {
69            entry.blocked = blocked;
70        }
71    }
72
73    fn owner(&self, point: Point) -> Option<ScrollContainerId> {
74        self.entries
75            .peek()
76            .iter()
77            .filter(|entry| entry.available && !entry.blocked && entry.rect.contains(point))
78            .min_by(|a, b| {
79                let aa = a.rect.width.max(0.0) * a.rect.height.max(0.0);
80                let ba = b.rect.width.max(0.0) * b.rect.height.max(0.0);
81                aa.total_cmp(&ba)
82            })
83            .map(|entry| entry.id)
84    }
85
86    fn unregister(&mut self, id: ScrollContainerId) {
87        if let Ok(mut entries) = self.entries.try_write() {
88            entries.retain(|entry| entry.id != id);
89        }
90    }
91}
92
93/// Which axes to auto-scroll.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum ScrollAxis {
96    /// Vertical only (the common case for lists).
97    #[default]
98    Y,
99    /// Horizontal only.
100    X,
101    /// Both.
102    Both,
103}
104
105/// Per-axis scroll delta for a pointer at `pos` inside `rect`.
106/// Returns `(dx, dy)`, each in `-speed..=speed`, scaled by how deep into the
107/// edge band the pointer is. Pure, for testability.
108pub fn edge_delta(
109    pos: Point,
110    rect: Rect,
111    threshold: f64,
112    speed: f64,
113    axis: ScrollAxis,
114) -> (f64, f64) {
115    // Only scroll while the pointer is within the container. Under pointer
116    // capture the container keeps receiving (bubbled) pointermove events even
117    // when the cursor is far outside it; without this gate the delta pins to
118    // full `speed` and the container scrolls forever. A pointer right at the
119    // edge still scrolls - `contains` is edge-inclusive.
120    if !rect.contains(pos) {
121        return (0.0, 0.0);
122    }
123    let threshold = threshold.max(1.0);
124    let speed = speed.max(0.0);
125    let ramp = |dist_into_band: f64| (dist_into_band / threshold).clamp(0.0, 1.0) * speed;
126    // Scroll toward whichever edge is nearer on this axis. Choosing the nearer
127    // edge (rather than a plain `if left else if right`) means a container
128    // narrower than `2 * threshold` - where the pointer is within the band of
129    // both edges at once - still scrolls both ways instead of the near edge
130    // always winning.
131    let edge = |lo: f64, hi: f64| -> f64 {
132        if lo <= hi {
133            if lo < threshold {
134                -ramp(threshold - lo)
135            } else {
136                0.0
137            }
138        } else if hi < threshold {
139            ramp(threshold - hi)
140        } else {
141            0.0
142        }
143    };
144    let mut dx = 0.0;
145    let mut dy = 0.0;
146    if matches!(axis, ScrollAxis::X | ScrollAxis::Both) {
147        dx = edge(pos.x - rect.x, rect.x + rect.width - pos.x);
148    }
149    if matches!(axis, ScrollAxis::Y | ScrollAxis::Both) {
150        dy = edge(pos.y - rect.y, rect.y + rect.height - pos.y);
151    }
152    (dx, dy)
153}
154
155/// Convert a pixels-per-second velocity into one frame's movement.
156pub fn frame_delta(velocity: (f64, f64), elapsed_seconds: f64) -> (f64, f64) {
157    let elapsed = if elapsed_seconds.is_finite() {
158        elapsed_seconds.clamp(0.0, 0.1)
159    } else {
160        0.0
161    };
162    (velocity.0 * elapsed, velocity.1 * elapsed)
163}
164
165fn animation_elapsed_seconds(elapsed_seconds: f32) -> f64 {
166    let elapsed_seconds = f64::from(elapsed_seconds);
167    if elapsed_seconds.is_finite() && elapsed_seconds > 0.0 {
168        elapsed_seconds
169    } else {
170        // The clock animation is 16 ms. A renderer that omits its elapsed
171        // duration still advances at the declared cadence.
172        0.016
173    }
174}
175
176/// Whether a pointer move should drive auto-scroll.
177///
178/// Mouse pointer drags report contact through held buttons. Touch and pen
179/// paths commonly report pressure during contact, and some platforms also
180/// expose held buttons for them.
181fn pointer_move_should_scroll(
182    pointer_type: &str,
183    pressure: f32,
184    has_held_button: bool,
185    active: Option<bool>,
186) -> bool {
187    match active {
188        Some(active) => active,
189        None => has_held_button || (pointer_type != "mouse" && pressure > 0.0),
190    }
191}
192
193/// Select a host-driven pointer sample only when the caller explicitly
194/// confirms that its drag is active. An externally retained coordinate must
195/// never keep scrolling idle or settling content.
196fn external_pointer_sample(active: Option<bool>, drag_pointer: Option<Point>) -> Option<Point> {
197    (active == Some(true)).then_some(drag_pointer).flatten()
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201enum ClockOwner {
202    Pointer,
203    NativeDrag,
204    External,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208struct ClockToken {
209    owner: ClockOwner,
210    epoch: u64,
211}
212
213fn resolved_velocity(speed: f64, speed_px_per_second: Option<f64>) -> f64 {
214    speed_px_per_second.unwrap_or(speed * 60.0)
215}
216
217/// A scrollable container that scrolls itself while a drag hovers near its
218/// edges. Give it the `overflow` CSS yourself (via `style`/`class`) - and
219/// consider `overscroll-behavior: contain` alongside it, so a wheel or
220/// touch scroll that hits the container's end mid-drag doesn't chain into
221/// scrolling the page. (The edge-scrolling itself is programmatic, clamps
222/// at the container's bounds, and never chains.)
223#[component]
224pub fn AutoScroll(
225    /// Edge band size in px.
226    #[props(default = 48.0)]
227    threshold: f64,
228    /// Legacy maximum movement per nominal 60 Hz frame. Kept for 3.x source
229    /// and behavior compatibility; new code should prefer
230    /// `speed_px_per_second`.
231    #[props(default = 24.0)]
232    speed: f64,
233    /// Exact maximum scroll velocity in CSS pixels per second. When set, this
234    /// takes precedence over the legacy `speed` prop.
235    #[props(default)]
236    speed_px_per_second: Option<f64>,
237    /// Axes to scroll.
238    #[props(default)]
239    axis: ScrollAxis,
240    /// Optional external drag-state gate. `Some(true)` scrolls on pointer
241    /// movement, `Some(false)` suppresses it, and `None` uses the built-in
242    /// pointer contact heuristic.
243    #[props(default)]
244    active: Option<bool>,
245    /// Optional pointer supplied by a host that tracks movement outside this
246    /// element's DOM event stream, expressed in this window's client
247    /// coordinates. The sample is used only with `active: Some(true)`; pass
248    /// the matching drag's live active state so a retained coordinate cannot
249    /// scroll idle or settling content.
250    #[props(default)]
251    drag_pointer: Option<Point>,
252    /// Fired with the container's scroll offset when a sample sees it
253    /// changed - after the auto-scroll's own scrolling, a wheel/trackpad
254    /// scroll, or pointer movement over the container - following the
255    /// rect-refresh ping. Drive a windowed (virtualized) list from
256    /// `offset.y`. See the module docs for how observation works and its
257    /// one blind spot.
258    #[props(default)]
259    on_scroll: Option<EventHandler<Point>>,
260    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
261    children: Element,
262) -> Element {
263    let max_velocity = resolved_velocity(speed, speed_px_per_second);
264    let mut mounted = use_signal(|| None::<Rc<MountedData>>);
265    // In-flight guard so a burst of dragover events doesn't queue a pile of
266    // overlapping async scrolls.
267    let busy = use_signal(|| false);
268    let mut latest_pointer = use_signal(|| None::<Point>);
269    let mut clock_running = use_signal(|| false);
270    let mut clock_generation = use_signal(|| 0u64);
271    let mut clock_owner = use_signal(|| None::<ClockOwner>);
272    let mut clock_epoch = use_signal(|| 0u64);
273    let mut native_drag_depth = use_signal(|| 0u32);
274    let scroll_id =
275        use_hook(|| ScrollContainerId(NEXT_SCROLL_CONTAINER.fetch_add(1, Ordering::Relaxed)));
276    let coordinator = use_hook(|| {
277        try_consume_context::<ScrollCoordinator>().unwrap_or_else(ScrollCoordinator::new)
278    });
279    use_context_provider(|| coordinator);
280    use_drop(move || {
281        let mut coordinator = coordinator;
282        coordinator.unregister(scroll_id);
283    });
284    // Scrolling this container moves everything inside it, so cached
285    // hit-test rects go stale the moment we scroll. Create-or-inherit the
286    // tree's rect-refresh channel: with a DndProvider above we join its
287    // channel; without one (self-contained sortables, native pages) we
288    // anchor a channel ourselves so the components inside can register.
289    let refresh = use_rect_refresh_provider();
290    // Last offset `sample` saw, deduplicating pings and on_scroll reports.
291    let last_offset = use_signal(Point::default);
292
293    // The observer: read the offset, and when it moved, ping the
294    // rect-refresh channel and report to on_scroll. Called from every
295    // event that can cause or accompany scrolling; the dedup makes the
296    // common nothing-changed case one cheap async read.
297    let sample = move || {
298        let Some(m) = mounted.peek().clone() else {
299            return;
300        };
301        let mut last_offset = last_offset;
302        spawn(async move {
303            if let Ok(o) = m.get_scroll_offset().await {
304                let now = Point::new(o.x, o.y);
305                if *last_offset.peek() != now {
306                    last_offset.set(now);
307                    // The zones inside just moved: re-measure (free while
308                    // no drag is in flight), then let the app re-slice its
309                    // window.
310                    refresh.refresh_all();
311                    if let Some(h) = &on_scroll {
312                        h.call(now);
313                    }
314                }
315            }
316        });
317    };
318
319    let scroll_for = move |point: Point, elapsed_seconds: f64, token: ClockToken| {
320        let Some(m) = mounted.peek().clone() else {
321            return;
322        };
323        let still_owned = move || {
324            *clock_running.peek()
325                && *clock_owner.peek() == Some(token.owner)
326                && *clock_epoch.peek() == token.epoch
327        };
328        if !still_owned() {
329            return;
330        }
331        if *busy.peek() {
332            return;
333        }
334        let mut busy = busy;
335        let mut clock_running = clock_running;
336        let mut coordinator = coordinator;
337        busy.set(true);
338        spawn(async move {
339            if let Ok(r) = m.get_client_rect().await {
340                if !still_owned() {
341                    busy.set(false);
342                    return;
343                }
344                let rect = Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height);
345                let velocity = edge_delta(point, rect, threshold, max_velocity, axis);
346                let (dx, dy) = frame_delta(velocity, elapsed_seconds);
347                let available = dx != 0.0 || dy != 0.0;
348                coordinator.update(scroll_id, rect, available);
349                if !available {
350                    if still_owned() {
351                        clock_running.set(false);
352                        clock_owner.set(None);
353                        clock_epoch += 1;
354                    }
355                    busy.set(false);
356                    return;
357                }
358                if coordinator.owner(point) != Some(scroll_id) {
359                    busy.set(false);
360                    return;
361                }
362                if dx != 0.0 || dy != 0.0 {
363                    if let Ok(offset) = m.get_scroll_offset().await {
364                        if !still_owned() {
365                            busy.set(false);
366                            return;
367                        }
368                        let _ = m
369                            .scroll(
370                                PixelsVector2D::new(offset.x + dx, offset.y + dy),
371                                ScrollBehavior::Instant,
372                            )
373                            .await;
374                        if !still_owned() {
375                            busy.set(false);
376                            return;
377                        }
378                        let moved = m
379                            .get_scroll_offset()
380                            .await
381                            .map(|after| after.x != offset.x || after.y != offset.y)
382                            .unwrap_or(true);
383                        coordinator.set_blocked(scroll_id, !moved);
384                        if !moved {
385                            clock_running.set(false);
386                            clock_owner.set(None);
387                            clock_epoch += 1;
388                        }
389                        // Everything just moved under the drag: re-measure
390                        // so hover and the eventual drop hit what the user
391                        // sees, not where things sat at pickup - and report
392                        // the new offset so a windowed list re-slices.
393                        refresh.refresh_all();
394                        sample();
395                    }
396                }
397            }
398            busy.set(false);
399        });
400    };
401
402    let mut start_clock = move |point: Point, owner: ClockOwner| {
403        latest_pointer.set(Some(point));
404        // A fresh pointer sample is a new opportunity for a container that
405        // previously hit a boundary. Geometry updates during an existing
406        // clock must not clear this bit, or the blocked inner container would
407        // repeatedly reclaim ownership before the outer surface can move.
408        let mut coordinator = coordinator;
409        coordinator.set_blocked(scroll_id, false);
410        if !*clock_running.peek() || *clock_owner.peek() != Some(owner) {
411            clock_owner.set(Some(owner));
412            clock_epoch += 1;
413            clock_running.set(true);
414            clock_generation += 1;
415        }
416    };
417    let mut stop_clock = move |owner: ClockOwner| {
418        if *clock_owner.peek() == Some(owner) {
419            clock_running.set(false);
420            clock_owner.set(None);
421            clock_epoch += 1;
422        }
423    };
424
425    // A host-driven receiver may be event-blind while another surface owns
426    // the pointer. React to its client-space feed through the same scroll
427    // path as DOM pointer movement, with the explicit active gate above.
428    use_effect(use_reactive!(|(active, drag_pointer)| {
429        if let Some(point) = external_pointer_sample(active, drag_pointer) {
430            start_clock(point, ClockOwner::External);
431        } else {
432            stop_clock(ClockOwner::External);
433        }
434    }));
435    let mut attributes = attributes;
436    crate::core::components::protect_attributes(
437        &mut attributes,
438        &[
439            "onmounted",
440            "onwheel",
441            "ondragenter",
442            "ondragover",
443            "ondragleave",
444            "ondrop",
445            "onpointermove",
446            "onpointerup",
447            "onpointercancel",
448        ],
449    );
450
451    rsx! {
452        style { style: "display: none;",
453            "@keyframes dnd-scroll-clock {{ from {{ opacity: 0.99; }} to {{ opacity: 1; }} }}"
454        }
455        div {
456            onmounted: move |evt: Event<MountedData>| {
457                mounted.set(Some(evt.data()));
458                // Report the initial offset (restored scroll positions
459                // exist) so windowing starts aligned.
460                sample();
461            },
462            // Wheel and trackpad scrolling, idle or mid-drag. Wheel events
463            // go to the element under the cursor regardless of pointer
464            // capture, and the sample's async offset read resolves after
465            // the browser applied the scroll this event causes.
466            onwheel: move |_| sample(),
467            // Native boundary drags: dragover fires continuously while
468            // hovering. The enter/leave depth prevents movement between
469            // descendants from looking like departure from this container.
470            // Note: no prevent_default here - drop permission stays the
471            // business of the zones inside.
472            ondragenter: move |_| native_drag_depth += 1,
473            ondragover: move |evt: DragEvent| {
474                if *native_drag_depth.peek() == 0 {
475                    native_drag_depth.set(1);
476                }
477                let c = evt.client_coordinates();
478                start_clock(Point::new(c.x, c.y), ClockOwner::NativeDrag);
479            },
480            ondragleave: move |_| {
481                let next = native_drag_depth.peek().saturating_sub(1);
482                native_drag_depth.set(next);
483                if next == 0 {
484                    stop_clock(ClockOwner::NativeDrag);
485                }
486            },
487            ondrop: move |_| {
488                native_drag_depth.set(0);
489                stop_clock(ClockOwner::NativeDrag);
490            },
491            // Pointer-driven drags: mouse uses held buttons, while touch and
492            // pen commonly report pressure during contact.
493            onpointermove: move |evt: PointerEvent| {
494                if pointer_move_should_scroll(
495                    &evt.pointer_type(),
496                    evt.pressure(),
497                    !evt.held_buttons().is_empty(),
498                    active,
499                ) {
500                    let c = evt.client_coordinates();
501                    start_clock(Point::new(c.x, c.y), ClockOwner::Pointer);
502                }
503                // Sample on every move, contact or hover: it trues up the
504                // window after scrollbar drags and programmatic scrolls
505                // the moment the pointer stirs.
506                sample();
507            },
508            onpointerup: move |_| stop_clock(ClockOwner::Pointer),
509            onpointercancel: move |_| stop_clock(ClockOwner::Pointer),
510            ..attributes,
511            if let Some(owner) = clock_running().then_some(clock_owner()).flatten() {
512                div {
513                    key: "{clock_generation}",
514                    style: "position: absolute; width: 0; height: 0; overflow: hidden; \
515                            animation: dnd-scroll-clock 16ms linear forwards;",
516                    aria_hidden: true,
517                    onanimationend: move |event: AnimationEvent| {
518                        let token = ClockToken {
519                            owner,
520                            epoch: *clock_epoch.peek(),
521                        };
522                        let elapsed = animation_elapsed_seconds(event.data().elapsed_time());
523                        if let Some(point) = *latest_pointer.peek() {
524                            scroll_for(point, elapsed, token);
525                        }
526                        if *clock_running.peek()
527                            && *clock_owner.peek() == Some(token.owner)
528                            && *clock_epoch.peek() == token.epoch
529                        {
530                            clock_generation += 1;
531                        }
532                    },
533                }
534            }
535            {children}
536        }
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use std::cell::Cell;
543
544    use super::*;
545
546    fn external_pointer_app() -> Element {
547        rsx! {
548            AutoScroll {
549                active: true,
550                drag_pointer: Point::new(5.0, 5.0),
551                "receiver"
552            }
553        }
554    }
555
556    #[test]
557    fn external_pointer_feed_is_available_without_dom_pointer_events() {
558        let mut dom = VirtualDom::new(external_pointer_app);
559        dom.rebuild_in_place();
560        assert!(dioxus_ssr::render(&dom).contains("receiver"));
561    }
562
563    #[derive(Clone, Props)]
564    struct DynamicPointerProps {
565        state: Rc<Cell<(Option<bool>, Option<Point>)>>,
566    }
567
568    impl PartialEq for DynamicPointerProps {
569        fn eq(&self, other: &Self) -> bool {
570            Rc::ptr_eq(&self.state, &other.state)
571        }
572    }
573
574    fn dynamic_pointer_app(props: DynamicPointerProps) -> Element {
575        let (active, drag_pointer) = props.state.get();
576        rsx! {
577            AutoScroll { active, drag_pointer, "receiver" }
578        }
579    }
580
581    fn flush_effects(dom: &mut VirtualDom) {
582        for _ in 0..3 {
583            dom.process_events();
584            dom.render_immediate(&mut dioxus::core::NoOpMutations);
585        }
586    }
587
588    #[test]
589    fn external_pointer_prop_changes_start_and_stop_the_clock() {
590        let state = Rc::new(Cell::new((Some(false), None)));
591        let mut dom = VirtualDom::new_with_props(
592            dynamic_pointer_app,
593            DynamicPointerProps {
594                state: state.clone(),
595            },
596        );
597        dom.rebuild_in_place();
598        flush_effects(&mut dom);
599        assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
600
601        state.set((Some(true), Some(Point::new(5.0, 5.0))));
602        dom.mark_dirty(ScopeId::APP);
603        flush_effects(&mut dom);
604        assert!(dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
605
606        state.set((None, Some(Point::new(5.0, 5.0))));
607        dom.mark_dirty(ScopeId::APP);
608        flush_effects(&mut dom);
609        assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
610
611        state.set((Some(true), Some(Point::new(5.0, 5.0))));
612        dom.mark_dirty(ScopeId::APP);
613        flush_effects(&mut dom);
614        assert!(dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
615
616        state.set((Some(true), None));
617        dom.mark_dirty(ScopeId::APP);
618        flush_effects(&mut dom);
619        assert!(!dioxus_ssr::render(&dom).contains("position: absolute; width: 0"));
620    }
621
622    #[test]
623    fn external_pointer_requires_an_explicit_active_gate() {
624        let point = Point::new(5.0, 5.0);
625        assert_eq!(
626            external_pointer_sample(Some(true), Some(point)),
627            Some(point)
628        );
629        assert_eq!(external_pointer_sample(Some(false), Some(point)), None);
630        assert_eq!(external_pointer_sample(None, Some(point)), None);
631        assert_eq!(external_pointer_sample(Some(true), None), None);
632    }
633
634    #[test]
635    fn legacy_speed_keeps_its_nominal_sixty_hertz_behavior() {
636        assert_eq!(resolved_velocity(24.0, None), 1440.0);
637        assert_eq!(resolved_velocity(24.0, Some(720.0)), 720.0);
638    }
639
640    #[test]
641    fn deltas_ramp_toward_edges() {
642        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
643        // dead center: no scroll
644        assert_eq!(
645            edge_delta(Point::new(100.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
646            (0.0, 0.0)
647        );
648        // near top: negative dy, magnitude below max
649        let (_, dy) = edge_delta(Point::new(100.0, 10.0), rect, 48.0, 24.0, ScrollAxis::Y);
650        assert!((-24.0..0.0).contains(&dy));
651        // at the very bottom edge: full speed down
652        let (_, dy) = edge_delta(Point::new(100.0, 400.0), rect, 48.0, 24.0, ScrollAxis::Y);
653        assert_eq!(dy, 24.0);
654        // axis filtering: Y-only ignores horizontal proximity
655        let (dx, _) = edge_delta(Point::new(1.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Y);
656        assert_eq!(dx, 0.0);
657    }
658
659    #[test]
660    fn velocity_is_scaled_by_elapsed_time() {
661        assert_eq!(frame_delta((600.0, -300.0), 0.02), (12.0, -6.0));
662        // A suspended tab cannot produce a giant catch-up jump.
663        assert_eq!(frame_delta((100.0, 100.0), 5.0), (10.0, 10.0));
664        assert_eq!(frame_delta((100.0, 100.0), f64::NAN), (0.0, 0.0));
665        assert_eq!(animation_elapsed_seconds(0.0), 0.016);
666        assert_eq!(animation_elapsed_seconds(f32::NAN), 0.016);
667    }
668
669    fn coordinator_probe() -> Element {
670        let mut coordinator = ScrollCoordinator::new();
671        let outer = ScrollContainerId(1);
672        let inner = ScrollContainerId(2);
673        let point = Point::new(50.0, 50.0);
674        coordinator.update(outer, Rect::new(0.0, 0.0, 200.0, 200.0), true);
675        coordinator.update(inner, Rect::new(25.0, 25.0, 50.0, 50.0), true);
676        assert_eq!(coordinator.owner(point), Some(inner));
677        coordinator.set_blocked(inner, true);
678        assert_eq!(coordinator.owner(point), Some(outer));
679        rsx! {}
680    }
681
682    #[test]
683    fn nested_coordinator_hands_boundary_to_outer_container() {
684        let mut dom = VirtualDom::new(coordinator_probe);
685        dom.rebuild_in_place();
686    }
687
688    #[test]
689    fn no_scroll_when_pointer_leaves_the_container() {
690        // Under pointer capture a bubbled move can report a cursor far outside
691        // the container; that must not scroll (previously it pinned to full
692        // speed forever).
693        let rect = Rect::new(0.0, 0.0, 200.0, 400.0);
694        assert_eq!(
695            edge_delta(Point::new(100.0, 900.0), rect, 48.0, 24.0, ScrollAxis::Both),
696            (0.0, 0.0)
697        );
698        assert_eq!(
699            edge_delta(Point::new(-50.0, 200.0), rect, 48.0, 24.0, ScrollAxis::Both),
700            (0.0, 0.0)
701        );
702    }
703
704    #[test]
705    fn narrow_container_scrolls_toward_the_nearer_edge() {
706        // 40px wide, band 48: the pointer is within both edges' bands, so the
707        // nearer edge must win rather than the left always winning.
708        let rect = Rect::new(0.0, 0.0, 40.0, 400.0);
709        let (dx, _) = edge_delta(Point::new(35.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
710        assert!(
711            dx > 0.0,
712            "near the right edge should scroll right, got {dx}"
713        );
714        let (dx, _) = edge_delta(Point::new(5.0, 200.0), rect, 48.0, 24.0, ScrollAxis::X);
715        assert!(dx < 0.0, "near the left edge should scroll left, got {dx}");
716    }
717
718    #[test]
719    fn pointer_scroll_predicate_matches_active_pointer_drags() {
720        assert!(
721            pointer_move_should_scroll("mouse", 0.0, true, None),
722            "default mouse pointer drags keep a held button during movement"
723        );
724        assert!(
725            !pointer_move_should_scroll("mouse", 0.0, false, None),
726            "passive mouse hover must not scroll"
727        );
728        assert!(
729            pointer_move_should_scroll("touch", 0.5, false, None),
730            "touch contact can report pressure instead of held buttons"
731        );
732        assert!(
733            pointer_move_should_scroll("pen", 0.0, true, None),
734            "pen contact can also surface as held buttons"
735        );
736        assert!(
737            !pointer_move_should_scroll("touch", 0.5, false, Some(false)),
738            "callers that track drag state can explicitly gate scrolling off"
739        );
740        assert!(
741            pointer_move_should_scroll("mouse", 0.0, false, Some(true)),
742            "callers that track drag state can explicitly gate scrolling on"
743        );
744    }
745}