1use 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
63pub const AUTO_SCROLL_EDGE_PX: f64 = 32.0;
70
71pub const AUTO_SCROLL_MAX_PX_PER_S: f64 = 600.0;
77
78#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct AutoScroll {
82 pub edge_px: f64,
86 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 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
129pub struct AutoScrollZone<State: 'static> {
131 child: AnyView<State>,
132 coordinator: DragCoordinator,
133 controller: ScrollController,
134 config: AutoScroll,
135}
136
137pub 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 pub fn config(mut self, config: AutoScroll) -> Self {
180 self.config = config;
181 self
182 }
183}
184
185pub struct AutoScrollZoneWidget {
187 child: ChildPod,
188 coordinator: DragCoordinator,
189 controller: ScrollController,
190 config: AutoScroll,
191 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 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 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 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 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 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 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 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 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 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 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 h.state.coordinator.update_pointer(Point::new(200.0, 250.0));
537 assert!(!h.frame());
538 assert_eq!(h.offset(), scrolled);
539
540 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 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 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 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 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 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 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 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 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}