Skip to main content

frust_widgets/drag/
auto_scroll.rs

1//! [`auto_scroll_zone()`]: edge auto-scroll for a scroll surface while a drag
2//! is in flight — a transparent wrapper that, when the dragged pointer sits
3//! near its top or bottom edge, drives a [`ScrollController`] toward that edge
4//! every frame.
5//!
6//! # The zone
7//!
8//! The wrapper sits around a scroll surface (a `ScrollView`/`ListView` holding
9//! the same [`ScrollController`]) and reads its own window-space bounds at
10//! paint — the same way a drop target reports its bounds, since layout carries
11//! no window origin. Each band of [`AutoScroll::edge_px`] inside the top and
12//! bottom edges is an edge zone. While the coordinator is `Dragging` and the
13//! session's pointer is inside one, the controller is jumped toward that edge
14//! at a speed proportional to the pointer's depth into the band: zero at the
15//! band's inner boundary, [`AutoScroll::max_px_per_s`] at the edge itself (see
16//! [`AutoScroll::velocity`]). A pointer outside the wrapper's bounds — above
17//! the surface, say — scrolls nothing.
18//!
19//! # Frame cadence
20//!
21//! Each paint that finds the pointer in a zone advances the controller by
22//! `velocity × dt` with [`ScrollController::jump_to`], clamped to
23//! `[0, max_offset]`, and calls [`PaintCtx::request_frame`] for the next step.
24//! `dt` is the difference of two [`PaintCtx::frame_time`]s; the first frame in
25//! a zone only seeds the clock (a zero-delta frame, the convention the scroll
26//! surface's own ballistic pump uses), so time spent outside a zone is never
27//! paid back as one large jump. The cadence stops — no further frame request,
28//! the clock unseeded — the first paint the pointer is outside every zone, the
29//! session is no longer `Dragging`, the controller is detached, or the offset
30//! already sits at the edge being scrolled toward.
31//!
32//! The jump is recorded before the child paints, so the attached surface
33//! drains it in that same paint and the drop targets inside it report their
34//! moved bounds; after the child has painted, the wrapper calls
35//! [`DragCoordinator::resolve_hover`] so a target that scrolled under a
36//! stationary pointer becomes the hovered one without waiting for the pointer
37//! to move. Both guarantees — the same-frame drain and the band geometry
38//! itself — hold only when the zone encloses the scroll surface directly, as
39//! [`auto_scroll_zone()`] is documented to be used: a zone wrapping only the
40//! surface's *content* (rather than a bound `ScrollView`/`ListView` whose own
41//! layout reports the viewport, not the content's full extent) reads that
42//! content's full, usually taller, extent at paint instead of the viewport,
43//! so its edge bands sit at the content's own edges rather than the
44//! viewport's.
45//!
46//! # Limits
47//!
48//! * Vertical only: the controller drives a single scroll axis, and the
49//!   shipped scroll surfaces scroll vertically.
50//! * Window-space bounds are the wrapper's paint origin, so a zone under a
51//!   transformed ancestor (a `pan_zoom`) measures an untransformed position,
52//!   like every other window-space consumer in this module.
53
54use frust_core::{
55    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FrameTime,
56    InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
57};
58use kurbo::{Point, Rect, Size};
59
60use super::coordinator::{DragCoordinator, DragState};
61use crate::ScrollController;
62
63/// The default depth, in logical px, of each edge zone
64/// ([`AutoScroll::edge_px`]).
65///
66/// **Community-approximate**: wide enough to hit deliberately with a finger
67/// and narrow enough not to trigger while hovering the first or last visible
68/// row; no platform publishes a drag auto-scroll band.
69pub const AUTO_SCROLL_EDGE_PX: f64 = 32.0;
70
71/// The default top speed, in logical px per second, reached at the very edge
72/// ([`AutoScroll::max_px_per_s`]).
73///
74/// **Community-approximate**: fast enough to cross a long list in a few
75/// seconds, slow enough that a target can still be aimed at while it moves.
76pub const AUTO_SCROLL_MAX_PX_PER_S: f64 = 600.0;
77
78/// How an [`auto_scroll_zone()`] scrolls: the edge band's depth and the speed
79/// at the edge.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct AutoScroll {
82    /// Depth of the band inside the top and bottom edges that scrolls, in
83    /// logical px (default [`AUTO_SCROLL_EDGE_PX`]). Zero, negative or
84    /// non-finite disables auto-scroll.
85    pub edge_px: f64,
86    /// Speed at the edge itself, in logical px per second (default
87    /// [`AUTO_SCROLL_MAX_PX_PER_S`]); it falls off linearly to zero at the
88    /// band's inner boundary. Zero, negative or non-finite disables
89    /// auto-scroll.
90    pub max_px_per_s: f64,
91}
92
93impl Default for AutoScroll {
94    fn default() -> Self {
95        Self {
96            edge_px: AUTO_SCROLL_EDGE_PX,
97            max_px_per_s: AUTO_SCROLL_MAX_PX_PER_S,
98        }
99    }
100}
101
102impl AutoScroll {
103    /// The scroll velocity, in px per second (negative toward the top), for a
104    /// pointer at `pointer` over a zone whose window-space bounds are `zone`.
105    ///
106    /// Zero outside `zone` and outside both edge bands. Inside a band the speed
107    /// is `max_px_per_s × depth / edge_px`, `depth` being how far past the
108    /// band's inner boundary the pointer is. A zone shorter than two bands has
109    /// them overlap; the nearer edge wins.
110    pub fn velocity(&self, zone: Rect, pointer: Point) -> f64 {
111        let usable = |v: f64| v.is_finite() && v > 0.0;
112        if !usable(self.edge_px) || !usable(self.max_px_per_s) || !zone.contains(pointer) {
113            return 0.0;
114        }
115        let from_top = pointer.y - zone.y0;
116        let from_bottom = zone.y1 - pointer.y;
117        let (distance, direction) = if from_top <= from_bottom {
118            (from_top, -1.0)
119        } else {
120            (from_bottom, 1.0)
121        };
122        if distance >= self.edge_px {
123            return 0.0;
124        }
125        direction * self.max_px_per_s * (self.edge_px - distance) / self.edge_px
126    }
127}
128
129/// A declarative auto-scroll wrapper. See the [module docs](self).
130pub struct AutoScrollZone<State: 'static> {
131    child: AnyView<State>,
132    coordinator: DragCoordinator,
133    controller: ScrollController,
134    config: AutoScroll,
135}
136
137/// Wrap `child` — a scroll surface holding `controller` — in an edge
138/// auto-scroll zone for sessions on `coordinator`, with the default
139/// [`AutoScroll`]. See the [module docs](self).
140///
141/// ```
142/// use frust_widgets::drag::{AutoScroll, DragCoordinator, auto_scroll_zone, drag_target};
143/// use frust_widgets::{ScrollController, SizedBox, scroll_view};
144///
145/// struct App;
146///
147/// let drag = DragCoordinator::new();
148/// let scroll = ScrollController::new();
149/// let column = scroll_view(SizedBox::<App>(None, Some(2_000.0))).controller(scroll.clone());
150/// let _zone = auto_scroll_zone(
151///     drag_target::<u32, App, _>(column, drag.clone()),
152///     drag.clone(),
153///     scroll.clone(),
154/// )
155/// .config(AutoScroll {
156///     edge_px: 48.0,
157///     ..AutoScroll::default()
158/// });
159/// ```
160pub fn auto_scroll_zone<State, V>(
161    child: V,
162    coordinator: DragCoordinator,
163    controller: ScrollController,
164) -> AutoScrollZone<State>
165where
166    State: 'static,
167    V: View<State>,
168{
169    AutoScrollZone {
170        child: any(child),
171        coordinator,
172        controller,
173        config: AutoScroll::default(),
174    }
175}
176
177impl<State: 'static> AutoScrollZone<State> {
178    /// The band depth and edge speed (default [`AutoScroll::default`]).
179    pub fn config(mut self, config: AutoScroll) -> Self {
180        self.config = config;
181        self
182    }
183}
184
185/// The retained widget for an [`AutoScrollZone`].
186pub struct AutoScrollZoneWidget {
187    child: ChildPod,
188    coordinator: DragCoordinator,
189    controller: ScrollController,
190    config: AutoScroll,
191    /// The frame time of the last auto-scroll step, or `None` while not
192    /// scrolling — the next step then only seeds it.
193    last_step: Option<FrameTime>,
194}
195
196impl<State: 'static> View<State> for AutoScrollZone<State> {
197    type Element = AutoScrollZoneWidget;
198
199    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
200        AutoScrollZoneWidget {
201            child: crate::authoring::build_child(&self.child, ctx),
202            coordinator: self.coordinator.clone(),
203            controller: self.controller.clone(),
204            config: self.config,
205            last_step: None,
206        }
207    }
208
209    fn rebuild(
210        &self,
211        prev: &Self,
212        element: &mut Self::Element,
213        ctx: &mut BuildCtx<'_>,
214    ) -> ChangeFlags {
215        // Read every paint, so adopting the latest values needs no flag.
216        element.coordinator = self.coordinator.clone();
217        element.controller = self.controller.clone();
218        element.config = self.config;
219        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
220    }
221
222    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
223        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
224    }
225}
226
227impl AutoScrollZoneWidget {
228    /// Take this frame's auto-scroll step for a zone at window-space `zone`,
229    /// returning whether a jump was recorded. See the [module
230    /// docs](self#frame-cadence).
231    fn step(&mut self, ctx: &mut PaintCtx, zone: Rect) -> bool {
232        let velocity = match self.coordinator.state() {
233            DragState::Dragging(session) if self.controller.is_attached() => {
234                self.config.velocity(zone, session.pointer)
235            }
236            _ => 0.0,
237        };
238        let offset = self.controller.offset();
239        let max_offset = self.controller.max_offset();
240        let room = (velocity < 0.0 && offset > 0.0) || (velocity > 0.0 && offset < max_offset);
241        if !room {
242            self.last_step = None;
243            return false;
244        }
245        let now = ctx.frame_time();
246        let dt = self
247            .last_step
248            .map_or(0.0, |last| now.saturating_sub(last).as_secs_f64());
249        self.last_step = Some(now);
250        ctx.request_frame();
251        if dt <= 0.0 {
252            return false;
253        }
254        self.controller
255            .jump_to((offset + velocity * dt).clamp(0.0, max_offset));
256        true
257    }
258}
259
260impl Widget for AutoScrollZoneWidget {
261    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
262        let size = self.child.layout_child(ctx, bc);
263        self.child.set_origin(Point::ZERO);
264        bc.constrain(size)
265    }
266
267    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
268        let zone = Rect::from_origin_size(ctx.origin(), ctx.size());
269        let jumped = self.step(ctx, zone);
270        self.child.paint_child(ctx, scene);
271        if jumped {
272            self.coordinator.resolve_hover();
273        }
274    }
275
276    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
277        crate::authoring::route_event_single(&mut self.child, ctx, event)
278    }
279
280    fn semantics(&self, ctx: &mut SemanticsCtx) {
281        self.child.semantics_child(ctx);
282    }
283
284    crate::authoring::visit_children!(child);
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::drag::{DragPhase, DragTargetId, drag_target, draggable};
291    use crate::test_support::RecordingScene;
292    use crate::{EdgeInsets, Padding, SizedBox, Stack, StackView, scroll_view, stack};
293    use frust_core::{PointerButton, PointerEvent, PointerPhase, RenderRoot};
294
295    const WINDOW: Size = Size::new(400.0, 600.0);
296    /// The zone's window-space bounds: the scroll viewport, 300 px tall.
297    const ZONE: Rect = Rect::new(0.0, 100.0, 400.0, 400.0);
298    const FRAME_MS: f64 = 16.0;
299    const ROW: f64 = 100.0;
300
301    #[derive(Clone, Copy)]
302    struct Cfg {
303        content_height: f64,
304        /// Fill the content with drop-target rows and a draggable.
305        populated: bool,
306    }
307
308    struct App {
309        cfg: Cfg,
310        coordinator: DragCoordinator,
311        controller: ScrollController,
312    }
313
314    fn logic(state: &mut App) -> StackView<App> {
315        let cfg = state.cfg;
316        let mut content = vec![any(SizedBox::<App>(
317            Some(WINDOW.width),
318            Some(cfg.content_height),
319        ))];
320        if cfg.populated {
321            let rows = (cfg.content_height / ROW) as usize;
322            for row in 0..rows {
323                content.push(any(Padding(
324                    EdgeInsets {
325                        left: 0.0,
326                        top: row as f64 * ROW,
327                        right: 0.0,
328                        bottom: 0.0,
329                    },
330                    drag_target::<u32, App, _>(
331                        SizedBox::<App>(Some(WINDOW.width), Some(ROW)),
332                        state.coordinator.clone(),
333                    ),
334                )));
335            }
336            content.push(any(Padding(
337                EdgeInsets {
338                    left: 10.0,
339                    top: 10.0,
340                    right: 0.0,
341                    bottom: 0.0,
342                },
343                draggable(
344                    SizedBox::<App>(Some(100.0), Some(40.0)),
345                    state.coordinator.clone(),
346                    |_: &App| 5_u32,
347                ),
348            )));
349        }
350        let surface = scroll_view(Stack(content)).controller(state.controller.clone());
351        stack().child(Padding(
352            EdgeInsets {
353                left: 0.0,
354                top: ZONE.y0,
355                right: 0.0,
356                bottom: WINDOW.height - ZONE.y1,
357            },
358            auto_scroll_zone(surface, state.coordinator.clone(), state.controller.clone()),
359        ))
360    }
361
362    struct Harness {
363        root: RenderRoot<App, StackView<App>>,
364        state: App,
365        clock_ms: f64,
366    }
367
368    impl Harness {
369        fn new(cfg: Cfg) -> Self {
370            let mut harness = Harness {
371                root: RenderRoot::new(),
372                state: App {
373                    cfg,
374                    coordinator: DragCoordinator::new(),
375                    controller: ScrollController::new(),
376                },
377                clock_ms: 0.0,
378            };
379            harness.frame();
380            harness
381        }
382
383        /// One whole frame — rebuild, layout, paint — returning whether it
384        /// asked for another.
385        fn frame(&mut self) -> bool {
386            let mut build: fn(&mut App) -> StackView<App> = logic;
387            self.root.rebuild(&mut build, &mut self.state);
388            self.root.layout(WINDOW);
389            self.clock_ms += FRAME_MS;
390            let mut scene = RecordingScene::default();
391            self.root
392                .paint(
393                    &mut scene,
394                    FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
395                )
396                .needs_frame
397        }
398
399        /// Start a session straight on the coordinator, pointer at `at`.
400        fn drag_at(&mut self, at: Point) {
401            let drag = &self.state.coordinator;
402            drag.arm(drag.new_source_id(), at);
403            drag.begin(0_u8);
404            drag.update_pointer(at);
405        }
406
407        fn offset(&self) -> f64 {
408            self.state.controller.offset()
409        }
410
411        fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
412            self.root.event(
413                &mut self.state,
414                &InputEvent::Pointer(PointerEvent {
415                    phase,
416                    position: Point::new(x, y),
417                    button: PointerButton::Primary,
418                }),
419            );
420        }
421
422        fn hovered(&self) -> Option<DragTargetId> {
423            self.state
424                .coordinator
425                .state()
426                .session()
427                .and_then(|s| s.hovered)
428        }
429    }
430
431    fn plain(content_height: f64) -> Harness {
432        Harness::new(Cfg {
433            content_height,
434            populated: false,
435        })
436    }
437
438    fn assert_close(actual: f64, expected: f64, what: &str) {
439        assert!(
440            (actual - expected).abs() < 1e-6,
441            "{what}: {actual} != {expected}"
442        );
443    }
444
445    #[test]
446    fn velocity_is_proportional_to_the_depth_into_an_edge_band() {
447        let config = AutoScroll::default();
448        let at = |y: f64| config.velocity(ZONE, Point::new(200.0, y));
449        assert_eq!(at(250.0), 0.0, "the middle scrolls nothing");
450        assert_eq!(at(ZONE.y0 + 32.0), 0.0, "the band's inner boundary");
451        assert_eq!(at(ZONE.y1 - 32.0), 0.0);
452        assert_close(at(ZONE.y0), -600.0, "the top edge itself");
453        assert_close(at(ZONE.y0 + 8.0), -450.0, "a quarter in from the top");
454        assert_close(at(ZONE.y1 - 16.0), 300.0, "halfway into the bottom band");
455        assert_eq!(at(ZONE.y0 - 1.0), 0.0, "above the zone");
456        assert_eq!(at(ZONE.y1 + 1.0), 0.0, "below the zone");
457        assert_eq!(
458            config.velocity(ZONE, Point::new(-1.0, ZONE.y1 - 1.0)),
459            0.0,
460            "beside the zone"
461        );
462        let off = AutoScroll {
463            edge_px: 0.0,
464            ..config
465        };
466        assert_eq!(off.velocity(ZONE, Point::new(200.0, ZONE.y0)), 0.0);
467        let off = AutoScroll {
468            max_px_per_s: f64::NAN,
469            ..config
470        };
471        assert_eq!(off.velocity(ZONE, Point::new(200.0, ZONE.y0)), 0.0);
472
473        // A zone shorter than two bands: the nearer edge wins.
474        let short = Rect::new(0.0, 0.0, 100.0, 40.0);
475        assert!(config.velocity(short, Point::new(50.0, 10.0)) < 0.0);
476        assert!(config.velocity(short, Point::new(50.0, 30.0)) > 0.0);
477    }
478
479    #[test]
480    fn auto_scroll_advances_the_controller_at_the_expected_rate() {
481        let mut h = plain(2_000.0);
482        assert_close(h.state.controller.max_offset(), 1_700.0, "max offset");
483        // 16 px into the 32 px bottom band: half of 600 px/s.
484        h.drag_at(Point::new(200.0, ZONE.y1 - 16.0));
485        assert!(h.frame(), "the seeding frame asks for the next");
486        assert_eq!(h.offset(), 0.0, "the seeding frame moves nothing");
487        for step in 1..=10 {
488            assert!(h.frame());
489            assert_close(
490                h.offset(),
491                300.0 * (FRAME_MS / 1_000.0) * step as f64,
492                "offset after a 16 ms step",
493            );
494        }
495        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
496    }
497
498    #[test]
499    fn auto_scroll_stops_at_max_offset_and_at_zero() {
500        let mut h = plain(400.0);
501        assert_close(h.state.controller.max_offset(), 100.0, "max offset");
502        // At the bottom edge: the full 600 px/s, 9.6 px a frame.
503        h.drag_at(Point::new(200.0, ZONE.y1 - 0.5));
504        let mut frames = 0;
505        while h.frame() {
506            frames += 1;
507            assert!(frames < 40, "auto-scroll never stopped");
508        }
509        assert_eq!(h.offset(), 100.0, "stopped exactly at max_offset");
510        assert!(!h.frame(), "and stays stopped");
511        assert_eq!(h.offset(), 100.0);
512
513        // Back up at the top edge, down to zero.
514        h.state
515            .coordinator
516            .update_pointer(Point::new(200.0, ZONE.y0));
517        let mut frames = 0;
518        while h.frame() {
519            frames += 1;
520            assert!(frames < 40, "auto-scroll never stopped");
521        }
522        assert_eq!(h.offset(), 0.0);
523    }
524
525    #[test]
526    fn auto_scroll_stops_when_the_pointer_leaves_the_band_or_the_session_ends() {
527        let mut h = plain(2_000.0);
528        h.drag_at(Point::new(200.0, ZONE.y1 - 16.0));
529        for _ in 0..4 {
530            h.frame();
531        }
532        let scrolled = h.offset();
533        assert!(scrolled > 0.0);
534
535        // Into the middle: no step, no frame request.
536        h.state.coordinator.update_pointer(Point::new(200.0, 250.0));
537        assert!(!h.frame());
538        assert_eq!(h.offset(), scrolled);
539
540        // Outside the zone entirely (below it): nothing either.
541        h.state
542            .coordinator
543            .update_pointer(Point::new(200.0, ZONE.y1 + 20.0));
544        assert!(!h.frame());
545        assert_eq!(h.offset(), scrolled);
546
547        // Back into the band after a long pause: the clock re-seeds rather
548        // than paying the pause back as one jump.
549        h.clock_ms += 5_000.0;
550        h.state
551            .coordinator
552            .update_pointer(Point::new(200.0, ZONE.y1 - 16.0));
553        assert!(h.frame());
554        assert_eq!(h.offset(), scrolled, "the re-entry frame only seeds");
555        h.frame();
556        assert_close(h.offset(), scrolled + 4.8, "one 16 ms step at 300 px/s");
557
558        // The session ends: the cadence stops with it.
559        let at_cancel = h.offset();
560        h.state.coordinator.cancel();
561        assert!(!h.frame());
562        assert_eq!(h.offset(), at_cancel);
563    }
564
565    #[test]
566    fn a_drag_inside_the_surface_keeps_its_session_and_resolves_rows_that_scroll_under_it() {
567        let mut h = Harness::new(Cfg {
568            content_height: 1_000.0,
569            populated: true,
570        });
571        let rows = h.state.coordinator.registered_targets();
572        assert_eq!(rows.len(), 10, "one target per row, in tree order");
573
574        // Press the draggable (content (10, 10)), begin the drag inside the
575        // scroll surface's own slop, then carry it 16 px into the bottom band,
576        // over row 2 (content y 284).
577        h.mouse(PointerPhase::Down, 30.0, ZONE.y0 + 20.0);
578        h.mouse(PointerPhase::Move, 30.0, ZONE.y0 + 28.0);
579        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
580        h.mouse(PointerPhase::Move, 30.0, ZONE.y1 - 16.0);
581        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
582        h.frame();
583        assert_eq!(h.hovered(), Some(rows[2]));
584
585        // 4.8 px a frame: row 3's top (content y 300) reaches the pointer
586        // after 16 px of scroll, without the pointer moving.
587        let mut frames = 0;
588        while h.offset() < 16.0 {
589            h.frame();
590            frames += 1;
591            assert!(frames < 10, "the surface never scrolled far enough");
592        }
593        assert_eq!(
594            h.state.coordinator.phase(),
595            DragPhase::Dragging,
596            "scrolling the surface under the drag does not end it"
597        );
598        assert_eq!(
599            h.hovered(),
600            Some(rows[3]),
601            "the row that scrolled under the pointer is hovered"
602        );
603
604        // Releasing there drops on that row.
605        h.mouse(PointerPhase::Up, 30.0, ZONE.y1 - 16.0);
606        match h.state.coordinator.state() {
607            DragState::Dropping { target, .. } => assert_eq!(target, rows[3]),
608            other => panic!("expected a drop, got {other:?}"),
609        }
610    }
611
612    #[test]
613    fn resolve_hover_after_the_zone_paints_does_not_flicker_a_later_sibling() {
614        use std::cell::RefCell;
615        use std::rc::Rc;
616
617        use crate::drag::DragStateChange;
618
619        // A later sibling sharing the coordinator, painted (and so
620        // registered) after the zone: a second kanban column, a `Stack`
621        // layer above, an overlay. Covers the whole window, including the
622        // zone's own bottom edge band.
623        struct App {
624            coordinator: DragCoordinator,
625            controller: ScrollController,
626        }
627
628        fn logic(state: &mut App) -> StackView<App> {
629            let column = scroll_view(SizedBox::<App>(Some(WINDOW.width), Some(1_000.0)))
630                .controller(state.controller.clone());
631            stack()
632                .child(Padding(
633                    EdgeInsets {
634                        left: 0.0,
635                        top: ZONE.y0,
636                        right: 0.0,
637                        bottom: WINDOW.height - ZONE.y1,
638                    },
639                    auto_scroll_zone(
640                        drag_target::<u32, App, _>(column, state.coordinator.clone()),
641                        state.coordinator.clone(),
642                        state.controller.clone(),
643                    ),
644                ))
645                .child(drag_target::<u32, App, _>(
646                    SizedBox::<App>(Some(WINDOW.width), Some(WINDOW.height)),
647                    state.coordinator.clone(),
648                ))
649        }
650
651        let mut root: RenderRoot<App, StackView<App>> = RenderRoot::new();
652        let mut state = App {
653            coordinator: DragCoordinator::new(),
654            controller: ScrollController::new(),
655        };
656        let log: Rc<RefCell<Vec<DragStateChange>>> = Rc::new(RefCell::new(Vec::new()));
657        let sink = Rc::clone(&log);
658        let _subscription = state
659            .coordinator
660            .subscribe(move |change| sink.borrow_mut().push(change));
661
662        let mut build: fn(&mut App) -> StackView<App> = logic;
663        let mut clock_ms = 0.0_f64;
664        root.rebuild(&mut build, &mut state);
665        root.layout(WINDOW);
666        clock_ms += FRAME_MS;
667        let mut scene = RecordingScene::default();
668        root.paint(
669            &mut scene,
670            FrameTime::from_nanos((clock_ms * 1_000_000.0) as u64),
671        );
672
673        let targets = state.coordinator.registered_targets();
674        assert_eq!(targets.len(), 2, "the zone's own target plus the sibling");
675        let sibling = targets[1];
676
677        // The sibling, registered after the zone's own target, wins the
678        // overlap at a point both cover — the pointer sits in the zone's
679        // bottom edge band, which the sibling also spans.
680        let source = state.coordinator.new_source_id();
681        let at = Point::new(200.0, ZONE.y1 - 16.0);
682        state.coordinator.arm(source, at);
683        state.coordinator.begin(0_u32);
684        state.coordinator.update_pointer(at);
685        assert_eq!(
686            state.coordinator.state().session().and_then(|s| s.hovered),
687            Some(sibling),
688            "the later-registered sibling wins the overlap"
689        );
690        log.borrow_mut().clear();
691
692        // Two more frames: the first only seeds the auto-scroll clock, the
693        // second actually jumps the surface, which is when the zone's own
694        // target repaints (and the zone calls `resolve_hover`) before the
695        // sibling — the later Stack child — gets its own turn to repaint
696        // this same frame.
697        for _ in 0..2 {
698            root.rebuild(&mut build, &mut state);
699            root.layout(WINDOW);
700            clock_ms += FRAME_MS;
701            let mut scene = RecordingScene::default();
702            root.paint(
703                &mut scene,
704                FrameTime::from_nanos((clock_ms * 1_000_000.0) as u64),
705            );
706        }
707
708        assert!(
709            log.borrow().is_empty(),
710            "the sibling simply has not repainted yet this frame, not culled: {:?}",
711            log.borrow()
712        );
713        assert_eq!(
714            state.coordinator.state().session().and_then(|s| s.hovered),
715            Some(sibling),
716            "the hover must not flicker away from the sibling mid-frame"
717        );
718    }
719}