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