1use apace_traits::events::{ApaceScrollEvent, DomEvent, DomEventData};
5use apace_traits::node_id::NodeId;
6use style::values::computed::Overflow;
7use web_time::{SystemTime, UNIX_EPOCH};
8
9use crate::BaseDocument;
10use crate::util::Point;
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum ScrollBehavior {
14 #[default]
15 Auto,
16 Instant,
17 Smooth,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum ScrollLogicalPosition {
22 Start,
23 Center,
24 End,
25 Nearest,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub(crate) enum ScrollTarget {
35 Node(NodeId),
36 Viewport,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq)]
41pub(crate) enum ScrollAmount {
42 To(Point<f64>),
44 By(Point<f64>),
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub(crate) enum ScrollOverflow {
51 Clamp,
53 Chain,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub(crate) enum ScrollSource {
63 User,
65 Programmatic,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub(crate) struct ScrollRequest {
71 pub(crate) target: ScrollTarget,
72 pub(crate) amount: ScrollAmount,
73 pub(crate) overflow: ScrollOverflow,
74 pub(crate) source: ScrollSource,
75 pub(crate) behavior: ScrollBehavior,
76 pub(crate) interrupt_animation: bool,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub(crate) struct FlingState {
83 pub(crate) target: NodeId,
84 pub(crate) last_seen_time: f64,
85 pub(crate) x_velocity: f64,
86 pub(crate) y_velocity: f64,
87}
88
89#[derive(Debug, Clone, PartialEq)]
92pub(crate) struct ScrollToState {
93 pub(crate) target: ScrollTarget,
95 pub(crate) start: Point<f64>,
97 pub(crate) end: Point<f64>,
99 pub(crate) start_time: f64,
101 pub(crate) duration: f64,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub(crate) enum ScrollAnimationState {
107 None,
108 Fling(FlingState),
109 ScrollTo(ScrollToState),
111}
112
113fn ease_in_out_cubic(t: f64) -> f64 {
117 if t < 0.5 {
118 4.0 * t * t * t
119 } else {
120 let f = 2.0 * t - 2.0;
121 1.0 + (f * f * f) / 2.0
122 }
123}
124
125impl BaseDocument {
126 pub(crate) fn scroll(
132 &mut self,
133 request: ScrollRequest,
134 dispatch_event: &mut dyn FnMut(DomEvent),
135 ) -> bool {
136 if request.interrupt_animation
137 && (matches!(self.scroll_animation, ScrollAnimationState::ScrollTo(_))
138 || request.source == ScrollSource::Programmatic)
139 {
140 self.scroll_animation = ScrollAnimationState::None;
141 }
142
143 let target = self.canonical_scroll_target(request.target);
144
145 if let (ScrollTarget::Node(node_id), ScrollAmount::By(delta), ScrollOverflow::Chain) =
148 (target, request.amount, request.overflow)
149 {
150 if let Some(has_changed) =
151 self.scroll_inner_content_by(node_id, delta, request, dispatch_event)
152 {
153 return has_changed;
154 }
155 }
156
157 let include_hidden = request.source == ScrollSource::Programmatic;
158 let (current, max) = self.scroll_state(target, include_hidden);
159 let unclamped = match request.amount {
160 ScrollAmount::To(to) => to,
161 ScrollAmount::By(by) => Point {
162 x: current.x + by.x,
163 y: current.y + by.y,
164 },
165 };
166 let end = Point {
167 x: unclamped.x.clamp(0.0, max.x),
168 y: unclamped.y.clamp(0.0, max.y),
169 };
170
171 if self.should_scroll_smoothly(target, request.behavior) {
172 self.start_scroll_animation(target, end);
173 return end != current;
174 }
175
176 let has_changed = self.write_scroll_offset(target, end, dispatch_event);
177
178 if request.overflow == ScrollOverflow::Chain {
180 let remainder = Point {
181 x: unclamped.x - end.x,
182 y: unclamped.y - end.y,
183 };
184 if remainder != Point::ZERO {
185 if let Some(next) = self.next_scroller_in_chain(target) {
186 let request = ScrollRequest {
187 target: next,
188 amount: ScrollAmount::By(remainder),
189 ..request
190 };
191 return has_changed | self.scroll(request, dispatch_event);
192 }
193 }
194 }
195
196 has_changed
197 }
198
199 fn scroll_inner_content_by(
202 &mut self,
203 node_id: NodeId,
204 delta: Point<f64>,
205 request: ScrollRequest,
206 dispatch_event: &mut dyn FnMut(DomEvent),
207 ) -> Option<bool> {
208 let node = self.nodes.get_mut(node_id)?;
209
210 if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
211 let target = sub_doc
212 .get_hover_node_id()
213 .map_or(ScrollTarget::Viewport, ScrollTarget::Node);
214 let request = ScrollRequest {
216 target,
217 amount: ScrollAmount::By(delta),
218 ..request
219 };
220 return Some(sub_doc.scroll(request, dispatch_event));
221 }
222
223 node.element_data()?.text_input_data()?;
225 let content_box_width = node.final_layout().content_box_width();
226 let content_box_height = node.final_layout().content_box_height();
227 let input = node
228 .element_data_mut()
229 .and_then(|el| el.text_input_data_mut())
230 .unwrap();
231
232 let mut remainder = delta;
235 if input.is_multiline {
236 remainder.y =
237 -(input.scroll_by(-delta.y as f32, content_box_width, content_box_height) as f64);
238 } else {
239 remainder.x =
240 -(input.scroll_by(-delta.x as f32, content_box_width, content_box_height) as f64);
241 }
242
243 let has_changed = remainder != delta;
244
245 if remainder != Point::ZERO {
246 if let Some(next) = self.next_scroller_in_chain(ScrollTarget::Node(node_id)) {
247 let request = ScrollRequest {
248 target: next,
249 amount: ScrollAmount::By(remainder),
250 ..request
251 };
252 return Some(has_changed | self.scroll(request, dispatch_event));
253 }
254 }
255
256 Some(has_changed)
257 }
258
259 fn next_scroller_in_chain(&self, target: ScrollTarget) -> Option<ScrollTarget> {
262 match target {
263 ScrollTarget::Viewport => None,
264 ScrollTarget::Node(node_id)
265 if self.nodes.get(node_id).is_some_and(|node| {
266 node.flags
267 .contains(crate::node::NodeFlags::CONTAIN_TOP_LAYER_SCROLL)
268 }) =>
269 {
270 None
271 }
272 ScrollTarget::Node(node_id) => Some(
273 self.nodes
274 .get(node_id)
275 .and_then(|node| node.parent)
276 .map_or(ScrollTarget::Viewport, ScrollTarget::Node),
277 ),
278 }
279 }
280
281 fn canonical_scroll_target(&self, target: ScrollTarget) -> ScrollTarget {
284 match target {
285 ScrollTarget::Node(node_id)
286 if self.try_root_element().is_some_and(|el| el.id == node_id) =>
287 {
288 ScrollTarget::Viewport
289 }
290 target => target,
291 }
292 }
293
294 pub(crate) fn write_scroll_offset(
297 &mut self,
298 target: ScrollTarget,
299 offset: Point<f64>,
300 dispatch_event: &mut dyn FnMut(DomEvent),
301 ) -> bool {
302 match self.canonical_scroll_target(target) {
303 ScrollTarget::Viewport => {
304 let initial = self.viewport_scroll();
305 self.set_viewport_scroll(offset);
306 if offset == initial {
307 return false;
308 }
309
310 if let Some(root) = self.try_root_element() {
311 let root_id = root.id;
312 let layout = *root.final_layout();
313 let scale = self.viewport.scale() as f64;
314 let event = ApaceScrollEvent {
315 scroll_top: offset.y,
316 scroll_left: offset.x,
317 scroll_width: layout.size.width.max(layout.scrollable_overflow_rect.right)
318 as i32,
319 scroll_height: layout
320 .size
321 .height
322 .max(layout.scrollable_overflow_rect.bottom)
323 as i32,
324 client_width: (self.viewport.window_size.0 as f64 / scale) as i32,
325 client_height: (self.viewport.window_size.1 as f64 / scale) as i32,
326 };
327 dispatch_event(DomEvent::new(root_id, DomEventData::Scroll(event)));
328 }
329
330 self.shell_provider.request_redraw();
331 true
332 }
333 ScrollTarget::Node(node_id) => {
334 let Some(node) = self.nodes.get_mut(node_id) else {
335 return false;
336 };
337
338 let initial = *node.scroll_offset();
339 *node.scroll_offset_mut() = offset;
340 if offset == initial {
341 return false;
342 }
343
344 let layout = *node.final_layout();
345 self.nodes.bump_geometry_generation();
346 let event = ApaceScrollEvent {
347 scroll_top: offset.y,
348 scroll_left: offset.x,
349 scroll_width: layout.scroll_width() as i32,
350 scroll_height: layout.scroll_height() as i32,
351 client_width: layout.size.width as i32,
352 client_height: layout.size.height as i32,
353 };
354 dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
355
356 self.show_scrollbars(node_id);
357 self.shell_provider.request_redraw();
358 true
359 }
360 }
361 }
362
363 pub fn scroll_node_by<F: FnMut(DomEvent)>(
364 &mut self,
365 node_id: NodeId,
366 x: f64,
367 y: f64,
368 dispatch_event: F,
369 ) {
370 self.scroll_node_by_has_changed(node_id, x, y, dispatch_event);
371 }
372
373 pub fn scroll_node_by_has_changed<F: FnMut(DomEvent)>(
377 &mut self,
378 node_id: NodeId,
379 x: f64,
380 y: f64,
381 mut dispatch_event: F,
382 ) -> bool {
383 self.scroll(
384 ScrollRequest {
385 target: ScrollTarget::Node(node_id),
386 amount: ScrollAmount::By(Point { x: -x, y: -y }),
389 overflow: ScrollOverflow::Chain,
390 source: ScrollSource::User,
391 behavior: ScrollBehavior::Instant,
392 interrupt_animation: false,
393 },
394 &mut dispatch_event,
395 )
396 }
397
398 pub fn scroll_viewport_by(&mut self, x: f64, y: f64) {
399 self.scroll_viewport_by_has_changed(x, y);
400 }
401
402 pub fn scroll_viewport_by_has_changed(&mut self, x: f64, y: f64) -> bool {
404 self.scroll(
405 ScrollRequest {
406 target: ScrollTarget::Viewport,
407 amount: ScrollAmount::By(Point { x: -x, y: -y }),
408 overflow: ScrollOverflow::Clamp,
409 source: ScrollSource::User,
410 behavior: ScrollBehavior::Instant,
411 interrupt_animation: false,
412 },
413 &mut |_| {},
414 )
415 }
416
417 pub(crate) fn scroll_chain_by(
418 &mut self,
419 anchor_node_id: Option<NodeId>,
420 scroll_x: f64,
421 scroll_y: f64,
422 dispatch_event: &mut dyn FnMut(DomEvent),
423 ) -> bool {
424 self.scroll(
425 ScrollRequest {
426 target: anchor_node_id.map_or(ScrollTarget::Viewport, ScrollTarget::Node),
427 amount: ScrollAmount::By(Point {
428 x: -scroll_x,
429 y: -scroll_y,
430 }),
431 overflow: ScrollOverflow::Chain,
432 source: ScrollSource::User,
433 behavior: ScrollBehavior::Instant,
434 interrupt_animation: true,
437 },
438 dispatch_event,
439 )
440 }
441
442 const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
444
445 pub(crate) fn scroll_state(
450 &self,
451 target: ScrollTarget,
452 include_hidden: bool,
453 ) -> (Point<f64>, Point<f64>) {
454 match self.canonical_scroll_target(target) {
455 ScrollTarget::Viewport => {
456 let (content_width, content_height) = match self.try_root_element() {
461 Some(root) => {
462 let layout = root.final_layout();
463 (
464 layout.size.width.max(layout.scrollable_overflow_rect.right) as f64,
465 layout
466 .size
467 .height
468 .max(layout.scrollable_overflow_rect.bottom)
469 as f64,
470 )
471 }
472 None => (0.0, 0.0),
473 };
474 let scale = self.viewport.scale() as f64;
475 let window_width = self.viewport.window_size.0 as f64 / scale;
476 let window_height = self.viewport.window_size.1 as f64 / scale;
477 let max = Point {
478 x: (content_width - window_width).max(0.0),
479 y: (content_height - window_height).max(0.0),
480 };
481 (self.viewport_scroll(), max)
482 }
483 ScrollTarget::Node(node_id) => {
484 let Some(node) = self.nodes.get(node_id) else {
485 return (Point::ZERO, Point::ZERO);
486 };
487
488 let scrollable = |overflow| match overflow {
491 Overflow::Scroll | Overflow::Auto => true,
492 Overflow::Hidden => include_hidden,
493 _ => false,
494 };
495 let (can_x_scroll, can_y_scroll) = node
496 .primary_styles()
497 .map(|styles| {
498 (
499 scrollable(styles.clone_overflow_x()),
500 scrollable(styles.clone_overflow_y()),
501 )
502 })
503 .unwrap_or((false, false));
504 let max = Point {
505 x: match can_x_scroll {
506 true => node.final_layout().scroll_width() as f64,
507 false => 0.0,
508 },
509 y: match can_y_scroll {
510 true => node.final_layout().scroll_height() as f64,
511 false => 0.0,
512 },
513 };
514 (*node.scroll_offset(), max)
515 }
516 }
517 }
518
519 fn start_scroll_animation(&mut self, target: ScrollTarget, end: Point<f64>) {
522 let start = self.scroll_state(target, true).0;
523
524 let start_time = SystemTime::now()
525 .duration_since(UNIX_EPOCH)
526 .unwrap()
527 .as_millis() as u64 as f64;
528
529 self.scroll_animation = ScrollAnimationState::ScrollTo(ScrollToState {
530 target,
531 start,
532 end,
533 start_time,
534 duration: Self::SMOOTH_SCROLL_DURATION_MS,
535 });
536
537 self.shell_provider.request_redraw();
539 }
540
541 fn should_scroll_smoothly(&self, target: ScrollTarget, behavior: ScrollBehavior) -> bool {
542 match behavior {
543 ScrollBehavior::Auto => {
544 let styled_node = match target {
545 ScrollTarget::Node(node_id) => self.nodes.get(node_id),
546 ScrollTarget::Viewport => self.try_root_element(),
547 };
548 styled_node.is_some_and(|node| {
549 node.primary_styles().is_some_and(|style| {
550 style.clone_scroll_behavior()
551 == style::computed_values::scroll_behavior::T::Smooth
552 })
553 })
554 }
555 ScrollBehavior::Instant => false,
556 ScrollBehavior::Smooth => true,
557 }
558 }
559
560 pub fn scroll_to(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
565 self.scroll_programmatically(node_id, ScrollAmount::To(Point { x, y }), behavior);
566 }
567
568 pub fn scroll_by(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
570 self.scroll_programmatically(node_id, ScrollAmount::By(Point { x, y }), behavior);
571 }
572
573 fn scroll_programmatically(
574 &mut self,
575 node_id: NodeId,
576 amount: ScrollAmount,
577 behavior: ScrollBehavior,
578 ) {
579 if self.nodes.get(node_id).is_none() {
580 return;
581 }
582
583 self.scroll(
585 ScrollRequest {
586 target: ScrollTarget::Node(node_id),
587 amount,
588 overflow: ScrollOverflow::Clamp,
589 source: ScrollSource::Programmatic,
590 behavior,
591 interrupt_animation: true,
592 },
593 &mut |_| {},
594 );
595 }
596
597 fn aligned_scroll_offset(
598 current: f64,
599 viewport_size: f64,
600 target_start: f64,
601 target_size: f64,
602 position: ScrollLogicalPosition,
603 ) -> f64 {
604 let target_end = target_start + target_size;
605 match position {
606 ScrollLogicalPosition::Start => target_start,
607 ScrollLogicalPosition::Center => target_start - (viewport_size - target_size) / 2.0,
608 ScrollLogicalPosition::End => target_end - viewport_size,
609 ScrollLogicalPosition::Nearest => {
610 let viewport_end = current + viewport_size;
611 if (target_start >= current && target_end <= viewport_end)
612 || (target_start <= current && target_end >= viewport_end)
613 {
614 current
615 } else {
616 let start_offset = target_start;
617 let end_offset = target_end - viewport_size;
618 if (start_offset - current).abs() < (end_offset - current).abs() {
619 start_offset
620 } else {
621 end_offset
622 }
623 }
624 }
625 }
626 }
627
628 pub fn scroll_into_view(
630 &mut self,
631 node_id: NodeId,
632 behavior: ScrollBehavior,
633 vertical: ScrollLogicalPosition,
634 horizontal: ScrollLogicalPosition,
635 ) {
636 let Some(node) = self.nodes.get(node_id) else {
637 return;
638 };
639 let target =
640 node.absolute_position(node.scroll_offset().x as f32, node.scroll_offset().y as f32);
641 let target_size = node.final_layout().size;
642 let Some(root_id) = self.try_root_element().map(|root| root.id) else {
643 return;
644 };
645 let scale = self.viewport.scale() as f64;
646 let viewport_width = self.viewport.window_size.0 as f64 / scale;
647 let viewport_height = self.viewport.window_size.1 as f64 / scale;
648 let x = Self::aligned_scroll_offset(
649 self.viewport_scroll().x,
650 viewport_width,
651 target.x as f64,
652 target_size.width as f64,
653 horizontal,
654 );
655 let y = Self::aligned_scroll_offset(
656 self.viewport_scroll().y,
657 viewport_height,
658 target.y as f64,
659 target_size.height as f64,
660 vertical,
661 );
662 self.scroll_to(root_id, x, y, behavior);
663 }
664
665 fn resolve_fragment_scroll_target(&self, fragment: &str) -> Option<Option<NodeId>> {
672 let decoded = percent_encoding::percent_decode_str(fragment)
674 .decode_utf8_lossy()
675 .into_owned();
676
677 if !decoded.is_empty() {
678 if let Some(node_id) = self.get_fragment_target(&decoded) {
679 return Some(Some(node_id));
680 }
681 }
682
683 if decoded.is_empty() || decoded.eq_ignore_ascii_case("top") {
686 return Some(None);
687 }
688
689 None
690 }
691
692 fn scroll_to_fragment_with_behavior(
693 &mut self,
694 fragment: &str,
695 behavior: ScrollBehavior,
696 ) -> bool {
697 match self.resolve_fragment_scroll_target(fragment) {
698 Some(Some(node_id)) => {
699 self.scroll_into_view(
700 node_id,
701 behavior,
702 ScrollLogicalPosition::Start,
703 ScrollLogicalPosition::Nearest,
704 );
705 true
706 }
707 Some(None) => {
708 let root_id = self.root_element().id;
709 self.scroll_to(root_id, 0.0, 0.0, behavior);
710 true
711 }
712 None => false,
713 }
714 }
715
716 pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
722 self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
723 }
724
725 pub fn scroll_to_fragment_smooth(&mut self, fragment: &str) -> bool {
728 self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Smooth)
729 }
730
731 pub fn resolve_scroll_animation(&mut self) {
732 match &mut self.scroll_animation {
733 ScrollAnimationState::Fling(fling_state) => {
734 let time_ms = SystemTime::now()
735 .duration_since(UNIX_EPOCH)
736 .unwrap()
737 .as_millis() as u64 as f64;
738
739 let time_diff_ms = time_ms - fling_state.last_seen_time;
740
741 let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
743
744 fling_state.x_velocity *= deceleration;
745 fling_state.y_velocity *= deceleration;
746 fling_state.last_seen_time = time_ms;
747 let fling_state = fling_state.clone();
748
749 let dx = fling_state.x_velocity * time_diff_ms;
750 let dy = fling_state.y_velocity * time_diff_ms;
751
752 self.scroll_chain_by(Some(fling_state.target), dx, dy, &mut |_| {});
753 if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
754 self.scroll_animation = ScrollAnimationState::None;
755 }
756 }
757 ScrollAnimationState::ScrollTo(scroll_to) => {
758 let scroll_to = scroll_to.clone();
759 let time_ms = SystemTime::now()
760 .duration_since(UNIX_EPOCH)
761 .unwrap()
762 .as_millis() as u64 as f64;
763
764 let progress = if scroll_to.duration <= 0.0 {
766 1.0
767 } else {
768 ((time_ms - scroll_to.start_time) / scroll_to.duration).clamp(0.0, 1.0)
769 };
770 let eased = ease_in_out_cubic(progress);
771
772 let target = Point {
774 x: scroll_to.start.x + (scroll_to.end.x - scroll_to.start.x) * eased,
775 y: scroll_to.start.y + (scroll_to.end.y - scroll_to.start.y) * eased,
776 };
777 self.write_scroll_offset(scroll_to.target, target, &mut |_| {});
779
780 if progress >= 1.0 {
781 self.scroll_animation = ScrollAnimationState::None;
782 }
783 }
784 ScrollAnimationState::None => {
785 }
787 }
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use crate::{DocumentConfig, UserAgentStylesheet, qual_name};
795 use apace_traits::shell::Viewport;
796
797 fn fixture() -> (BaseDocument, NodeId) {
798 let mut doc = BaseDocument::new(DocumentConfig {
799 viewport: Some(Viewport::new(400, 300, 1., Default::default())),
800 ..Default::default()
801 });
802 doc.add_user_agent_stylesheet(UserAgentStylesheet::parse(
803 "html,body{margin:0;padding:0}section{width:200px;height:100px;overflow:auto}article{height:1000px}",
804 ));
805 let root = doc.root_node().id;
806 let mut m = doc.mutate();
807 let html = m.create_element(qual_name!("html", html), vec![]);
808 let body = m.create_element(qual_name!("body", html), vec![]);
809 let panel = m.create_element(qual_name!("section", html), vec![]);
810 let content = m.create_element(qual_name!("article", html), vec![]);
811 m.append_children(root, &[html]);
812 m.append_children(html, &[body]);
813 m.append_children(body, &[panel]);
814 m.append_children(panel, &[content]);
815 drop(m);
816 doc.resolve(0.);
817 doc.scroll_to(panel, 0., 300., ScrollBehavior::Instant);
818 assert_eq!(doc.get_node(panel).unwrap().scroll_offset().y, 300.);
819 let now = SystemTime::now()
820 .duration_since(UNIX_EPOCH)
821 .unwrap()
822 .as_millis() as f64;
823 doc.scroll_animation = ScrollAnimationState::Fling(FlingState {
824 target: panel,
825 last_seen_time: now - 16.,
826 x_velocity: 0.,
827 y_velocity: -1.,
828 });
829 (doc, panel)
830 }
831
832 #[test]
833 fn explicit_programmatic_scroll_retires_touch_fling_before_later_frames() {
834 for relative in [false, true] {
835 let (mut doc, panel) = fixture();
836 if relative {
837 doc.scroll_by(panel, 0., -300., ScrollBehavior::Instant);
838 } else {
839 doc.scroll_to(panel, 0., 0., ScrollBehavior::Instant);
840 }
841 assert_eq!(doc.scroll_animation, ScrollAnimationState::None);
842 doc.resolve_scroll_animation();
843 assert_eq!(doc.get_node(panel).unwrap().scroll_offset().y, 0.);
844 }
845 }
846
847 #[test]
848 fn advancing_touch_fling_keeps_its_animation_alive() {
849 let (mut doc, panel) = fixture();
850 doc.resolve_scroll_animation();
851 assert!(matches!(
852 doc.scroll_animation,
853 ScrollAnimationState::Fling(_)
854 ));
855 assert!(doc.get_node(panel).unwrap().scroll_offset().y > 300.);
856 }
857}