1use 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
64pub const AUTO_SCROLL_EDGE_PX: f64 = 32.0;
71
72pub const AUTO_SCROLL_MAX_PX_PER_S: f64 = 600.0;
78
79#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct AutoScroll {
83 pub edge_px: f64,
87 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 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
130pub struct AutoScrollZone<State: 'static> {
132 child: AnyView<State>,
133 coordinator: DragCoordinator,
134 controller: ScrollController,
135 config: AutoScroll,
136}
137
138pub 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 pub fn config(mut self, config: AutoScroll) -> Self {
181 self.config = config;
182 self
183 }
184}
185
186pub struct AutoScrollZoneWidget {
188 child: ChildPod,
189 coordinator: DragCoordinator,
190 controller: ScrollController,
191 config: AutoScroll,
192 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 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 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 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 fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
304 self.child.set_origin(Point::ZERO);
305 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 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 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 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 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 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 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 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 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 h.state.coordinator.update_pointer(Point::new(200.0, 250.0));
572 assert!(!h.frame());
573 assert_eq!(h.offset(), scrolled);
574
575 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 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 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 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 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 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 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 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 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}