Skip to main content

blitz_dom/
scrolling.rs

1//! Scrolling: user-initiated (interactive) and programmatic scrolling of nodes and the
2//! viewport, and the scroll animations (smooth scrolls and flings) which drive them.
3
4use blitz_traits::events::{BlitzScrollEvent, DomEvent, DomEventData};
5use blitz_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/// What a scroll applies to.
29///
30/// Per the CSS overflow propagation rules the root element has no scrolling mechanism of its
31/// own (its overflow is applied to the viewport), so [`ScrollTarget::Node`] holding the root
32/// element is equivalent to [`ScrollTarget::Viewport`].
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub(crate) enum ScrollTarget {
35    Node(NodeId),
36    Viewport,
37}
38
39/// How far to scroll.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub(crate) enum ScrollAmount {
42    /// An absolute scroll offset.
43    To(Point<f64>),
44    /// A delta to apply to the current scroll offset.
45    By(Point<f64>),
46}
47
48/// What to do with scroll which the target cannot consume.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub(crate) enum ScrollOverflow {
51    /// Discard it: the scroll affects exactly one scroller.
52    Clamp,
53    /// Transfer it to the next scroller in the scroll chain (the parent node, and eventually
54    /// the viewport).
55    Chain,
56}
57
58/// Who initiated a scroll. Determines which overflow values count as scrollable:
59/// `overflow: hidden` boxes have a scrolling box which programmatic scrolls apply to,
60/// but no user scrolling mechanism.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub(crate) enum ScrollSource {
63    /// A user-initiated scroll (wheel, touch, scrollbar drag, keyboard).
64    User,
65    /// A script-initiated scroll (`scrollTo` and friends, fragment navigation).
66    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    /// Whether to abort a smooth scroll in progress. Set for user-initiated scrolls, so that
77    /// an animation does not fight the user's input for the rest of its duration.
78    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/// State driving a smooth (animated) scroll towards a target offset. Used for
90/// fragment navigation (`#anchor` links) and programmatic smooth scrolling.
91#[derive(Debug, Clone, PartialEq)]
92pub(crate) struct ScrollToState {
93    /// What is being scrolled.
94    pub(crate) target: ScrollTarget,
95    /// The scroll offset at the start of the animation.
96    pub(crate) start: Point<f64>,
97    /// The scroll offset to animate towards.
98    pub(crate) end: Point<f64>,
99    /// Time (in milliseconds since the Unix epoch) at which the animation started.
100    pub(crate) start_time: f64,
101    /// Total duration of the animation in milliseconds.
102    pub(crate) duration: f64,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub(crate) enum ScrollAnimationState {
107    None,
108    Fling(FlingState),
109    /// A smooth scroll of the viewport towards a target offset.
110    ScrollTo(ScrollToState),
111}
112
113/// Cubic ease-in-out easing function, mapping a normalised time `t` in `[0, 1]`
114/// to an eased progress value in `[0, 1]`. Used to give smooth scrolls a natural
115/// acceleration/deceleration curve.
116fn 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    /// Apply a scroll to the document, returning whether anything moved.
127    ///
128    /// This is the single scrolling primitive: user-initiated scrolls
129    /// ([`BaseDocument::scroll_chain_by`]) and programmatic scrolls
130    /// ([`BaseDocument::scroll_to`]) differ only in the [`ScrollRequest`] they build.
131    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        {
139            self.scroll_animation = ScrollAnimationState::None;
140        }
141
142        let target = self.canonical_scroll_target(request.target);
143
144        // Text inputs and sub-documents scroll their own content rather than an overflow
145        // scrollport, so they only take part in the chained (user-initiated) path.
146        if let (ScrollTarget::Node(node_id), ScrollAmount::By(delta), ScrollOverflow::Chain) =
147            (target, request.amount, request.overflow)
148        {
149            if let Some(has_changed) =
150                self.scroll_inner_content_by(node_id, delta, request, dispatch_event)
151            {
152                return has_changed;
153            }
154        }
155
156        let include_hidden = request.source == ScrollSource::Programmatic;
157        let (current, max) = self.scroll_state(target, include_hidden);
158        let unclamped = match request.amount {
159            ScrollAmount::To(to) => to,
160            ScrollAmount::By(by) => Point {
161                x: current.x + by.x,
162                y: current.y + by.y,
163            },
164        };
165        let end = Point {
166            x: unclamped.x.clamp(0.0, max.x),
167            y: unclamped.y.clamp(0.0, max.y),
168        };
169
170        if self.should_scroll_smoothly(target, request.behavior) {
171            self.start_scroll_animation(target, end);
172            return end != current;
173        }
174
175        let has_changed = self.write_scroll_offset(target, end, dispatch_event);
176
177        // Transfer the delta the target could not consume to the next scroller in the chain.
178        if request.overflow == ScrollOverflow::Chain {
179            let remainder = Point {
180                x: unclamped.x - end.x,
181                y: unclamped.y - end.y,
182            };
183            if remainder != Point::ZERO {
184                if let Some(next) = self.next_scroller_in_chain(target) {
185                    let request = ScrollRequest {
186                        target: next,
187                        amount: ScrollAmount::By(remainder),
188                        ..request
189                    };
190                    return has_changed | self.scroll(request, dispatch_event);
191                }
192            }
193        }
194
195        has_changed
196    }
197
198    /// Scroll a node which scrolls content of its own rather than an overflow scrollport
199    /// (a text input or a sub-document), returning `None` if the node is not such a node.
200    fn scroll_inner_content_by(
201        &mut self,
202        node_id: NodeId,
203        delta: Point<f64>,
204        request: ScrollRequest,
205        dispatch_event: &mut dyn FnMut(DomEvent),
206    ) -> Option<bool> {
207        let node = self.nodes.get_mut(node_id)?;
208
209        if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
210            let target = sub_doc
211                .get_hover_node_id()
212                .map_or(ScrollTarget::Viewport, ScrollTarget::Node);
213            // TODO: propagate the remaining scroll to the outer document
214            let request = ScrollRequest {
215                target,
216                amount: ScrollAmount::By(delta),
217                ..request
218            };
219            return Some(sub_doc.scroll(request, dispatch_event));
220        }
221
222        // Single-line inputs scroll their text horizontally, multi-line inputs vertically.
223        node.element_data()?.text_input_data()?;
224        let content_box_width = node.final_layout().content_box_width();
225        let content_box_height = node.final_layout().content_box_height();
226        let input = node
227            .element_data_mut()
228            .and_then(|el| el.text_input_data_mut())
229            .unwrap();
230
231        // `TextInputData::scroll_by` takes (and returns the unconsumed part of) a delta in
232        // the opposite direction to a scroll offset.
233        let mut remainder = delta;
234        if input.is_multiline {
235            remainder.y =
236                -(input.scroll_by(-delta.y as f32, content_box_width, content_box_height) as f64);
237        } else {
238            remainder.x =
239                -(input.scroll_by(-delta.x as f32, content_box_width, content_box_height) as f64);
240        }
241
242        let has_changed = remainder != delta;
243
244        if remainder != Point::ZERO {
245            if let Some(next) = self.next_scroller_in_chain(ScrollTarget::Node(node_id)) {
246                let request = ScrollRequest {
247                    target: next,
248                    amount: ScrollAmount::By(remainder),
249                    ..request
250                };
251                return Some(has_changed | self.scroll(request, dispatch_event));
252            }
253        }
254
255        Some(has_changed)
256    }
257
258    /// The scroller which unconsumed scroll is transferred to: the node's parent, or the
259    /// viewport once the chain runs out of nodes.
260    fn next_scroller_in_chain(&self, target: ScrollTarget) -> Option<ScrollTarget> {
261        match target {
262            ScrollTarget::Viewport => None,
263            ScrollTarget::Node(node_id) => Some(
264                self.nodes
265                    .get(node_id)
266                    .and_then(|node| node.parent)
267                    .map_or(ScrollTarget::Viewport, ScrollTarget::Node),
268            ),
269        }
270    }
271
272    /// Resolve a scroll target to the scroller which actually moves: the root element scrolls
273    /// the viewport, per the CSS overflow propagation rules.
274    fn canonical_scroll_target(&self, target: ScrollTarget) -> ScrollTarget {
275        match target {
276            ScrollTarget::Node(node_id)
277                if self.try_root_element().is_some_and(|el| el.id == node_id) =>
278            {
279                ScrollTarget::Viewport
280            }
281            target => target,
282        }
283    }
284
285    /// Write a scroll target's offset (which must already be clamped to its scrollable
286    /// range), dispatching a `scroll` event and returning whether the offset changed.
287    pub(crate) fn write_scroll_offset(
288        &mut self,
289        target: ScrollTarget,
290        offset: Point<f64>,
291        dispatch_event: &mut dyn FnMut(DomEvent),
292    ) -> bool {
293        match self.canonical_scroll_target(target) {
294            ScrollTarget::Viewport => {
295                let initial = self.viewport_scroll;
296                self.viewport_scroll = offset;
297                if offset == initial {
298                    return false;
299                }
300
301                if let Some(root) = self.try_root_element() {
302                    let root_id = root.id;
303                    let layout = *root.final_layout();
304                    let scale = self.viewport.scale() as f64;
305                    let event = BlitzScrollEvent {
306                        scroll_top: offset.y,
307                        scroll_left: offset.x,
308                        scroll_width: layout.size.width.max(layout.scrollable_overflow_rect.right)
309                            as i32,
310                        scroll_height: layout
311                            .size
312                            .height
313                            .max(layout.scrollable_overflow_rect.bottom)
314                            as i32,
315                        client_width: (self.viewport.window_size.0 as f64 / scale) as i32,
316                        client_height: (self.viewport.window_size.1 as f64 / scale) as i32,
317                    };
318                    dispatch_event(DomEvent::new(root_id, DomEventData::Scroll(event)));
319                }
320
321                self.shell_provider.request_redraw();
322                true
323            }
324            ScrollTarget::Node(node_id) => {
325                let Some(node) = self.nodes.get_mut(node_id) else {
326                    return false;
327                };
328
329                let initial = *node.scroll_offset();
330                *node.scroll_offset_mut() = offset;
331                if offset == initial {
332                    return false;
333                }
334
335                let layout = *node.final_layout();
336                let event = BlitzScrollEvent {
337                    scroll_top: offset.y,
338                    scroll_left: offset.x,
339                    scroll_width: layout.scroll_width() as i32,
340                    scroll_height: layout.scroll_height() as i32,
341                    client_width: layout.size.width as i32,
342                    client_height: layout.size.height as i32,
343                };
344                dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
345
346                self.show_scrollbars(node_id);
347                self.shell_provider.request_redraw();
348                true
349            }
350        }
351    }
352
353    pub fn scroll_node_by<F: FnMut(DomEvent)>(
354        &mut self,
355        node_id: NodeId,
356        x: f64,
357        y: f64,
358        dispatch_event: F,
359    ) {
360        self.scroll_node_by_has_changed(node_id, x, y, dispatch_event);
361    }
362
363    /// Scroll a node by given x and y
364    /// Will bubble scrolling up to parent node once it can no longer scroll further
365    /// If we're already at the root node, bubbles scrolling up to the viewport
366    pub fn scroll_node_by_has_changed<F: FnMut(DomEvent)>(
367        &mut self,
368        node_id: NodeId,
369        x: f64,
370        y: f64,
371        mut dispatch_event: F,
372    ) -> bool {
373        self.scroll(
374            ScrollRequest {
375                target: ScrollTarget::Node(node_id),
376                // A user-facing scroll delta moves the content, i.e. the opposite direction
377                // to the scroll offset.
378                amount: ScrollAmount::By(Point { x: -x, y: -y }),
379                overflow: ScrollOverflow::Chain,
380                source: ScrollSource::User,
381                behavior: ScrollBehavior::Instant,
382                interrupt_animation: false,
383            },
384            &mut dispatch_event,
385        )
386    }
387
388    pub fn scroll_viewport_by(&mut self, x: f64, y: f64) {
389        self.scroll_viewport_by_has_changed(x, y);
390    }
391
392    /// Scroll the viewport by the given values
393    pub fn scroll_viewport_by_has_changed(&mut self, x: f64, y: f64) -> bool {
394        self.scroll(
395            ScrollRequest {
396                target: ScrollTarget::Viewport,
397                amount: ScrollAmount::By(Point { x: -x, y: -y }),
398                overflow: ScrollOverflow::Clamp,
399                source: ScrollSource::User,
400                behavior: ScrollBehavior::Instant,
401                interrupt_animation: false,
402            },
403            &mut |_| {},
404        )
405    }
406
407    pub(crate) fn scroll_chain_by(
408        &mut self,
409        anchor_node_id: Option<NodeId>,
410        scroll_x: f64,
411        scroll_y: f64,
412        dispatch_event: &mut dyn FnMut(DomEvent),
413    ) -> bool {
414        self.scroll(
415            ScrollRequest {
416                target: anchor_node_id.map_or(ScrollTarget::Viewport, ScrollTarget::Node),
417                amount: ScrollAmount::By(Point {
418                    x: -scroll_x,
419                    y: -scroll_y,
420                }),
421                overflow: ScrollOverflow::Chain,
422                source: ScrollSource::User,
423                behavior: ScrollBehavior::Instant,
424                // A user-initiated scroll aborts any smooth scroll in progress, so that the
425                // two do not fight over the scroll offset for the rest of the animation.
426                interrupt_animation: true,
427            },
428            dispatch_event,
429        )
430    }
431
432    /// Duration (in milliseconds) of an animated scroll.
433    const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
434
435    /// Returns the current scroll offset and the maximum scroll offset (the minimum is
436    /// always `0`) for the given scroll target. `include_hidden` controls whether
437    /// `overflow: hidden` axes count as scrollable (they do for programmatic scrolls,
438    /// which apply to any scrolling box, but not for user scrolls).
439    pub(crate) fn scroll_state(
440        &self,
441        target: ScrollTarget,
442        include_hidden: bool,
443    ) -> (Point<f64>, Point<f64>) {
444        match self.canonical_scroll_target(target) {
445            ScrollTarget::Viewport => {
446                // The viewport scrolls the root element's scrollable overflow, which includes
447                // both the root element itself and any content which overflows it (e.g. when
448                // the root element has a fixed height but its content is taller). A document
449                // without a root element has no scrollable content.
450                let (content_width, content_height) = match self.try_root_element() {
451                    Some(root) => {
452                        let layout = root.final_layout();
453                        (
454                            layout.size.width.max(layout.scrollable_overflow_rect.right) as f64,
455                            layout
456                                .size
457                                .height
458                                .max(layout.scrollable_overflow_rect.bottom)
459                                as f64,
460                        )
461                    }
462                    None => (0.0, 0.0),
463                };
464                let scale = self.viewport.scale() as f64;
465                let window_width = self.viewport.window_size.0 as f64 / scale;
466                let window_height = self.viewport.window_size.1 as f64 / scale;
467                let max = Point {
468                    x: (content_width - window_width).max(0.0),
469                    y: (content_height - window_height).max(0.0),
470                };
471                (self.viewport_scroll, max)
472            }
473            ScrollTarget::Node(node_id) => {
474                let Some(node) = self.nodes.get(node_id) else {
475                    return (Point::ZERO, Point::ZERO);
476                };
477
478                // An axis with a non-scrolling overflow value has no scrollable range, even
479                // when its content overflows.
480                let scrollable = |overflow| match overflow {
481                    Overflow::Scroll | Overflow::Auto => true,
482                    Overflow::Hidden => include_hidden,
483                    _ => false,
484                };
485                let (can_x_scroll, can_y_scroll) = node
486                    .primary_styles()
487                    .map(|styles| {
488                        (
489                            scrollable(styles.clone_overflow_x()),
490                            scrollable(styles.clone_overflow_y()),
491                        )
492                    })
493                    .unwrap_or((false, false));
494                let max = Point {
495                    x: match can_x_scroll {
496                        true => node.final_layout().scroll_width() as f64,
497                        false => 0.0,
498                    },
499                    y: match can_y_scroll {
500                        true => node.final_layout().scroll_height() as f64,
501                        false => 0.0,
502                    },
503                };
504                (*node.scroll_offset(), max)
505            }
506        }
507    }
508
509    /// Start a smooth (animated) scroll towards the given absolute scroll offset. The
510    /// animation is advanced each frame in [`BaseDocument::resolve_scroll_animation`].
511    fn start_scroll_animation(&mut self, target: ScrollTarget, end: Point<f64>) {
512        let start = self.scroll_state(target, true).0;
513
514        let start_time = SystemTime::now()
515            .duration_since(UNIX_EPOCH)
516            .unwrap()
517            .as_millis() as u64 as f64;
518
519        self.scroll_animation = ScrollAnimationState::ScrollTo(ScrollToState {
520            target,
521            start,
522            end,
523            start_time,
524            duration: Self::SMOOTH_SCROLL_DURATION_MS,
525        });
526
527        // Ensure the frame loop runs so the animation is driven to completion.
528        self.shell_provider.request_redraw();
529    }
530
531    fn should_scroll_smoothly(&self, target: ScrollTarget, behavior: ScrollBehavior) -> bool {
532        match behavior {
533            ScrollBehavior::Auto => {
534                let styled_node = match target {
535                    ScrollTarget::Node(node_id) => self.nodes.get(node_id),
536                    ScrollTarget::Viewport => self.try_root_element(),
537                };
538                styled_node.is_some_and(|node| {
539                    node.primary_styles().is_some_and(|style| {
540                        style.clone_scroll_behavior()
541                            == style::computed_values::scroll_behavior::T::Smooth
542                    })
543                })
544            }
545            ScrollBehavior::Instant => false,
546            ScrollBehavior::Smooth => true,
547        }
548    }
549
550    /// Scroll an element to the given absolute scroll offset in CSS pixels.
551    ///
552    /// Unlike a user-initiated scroll, a programmatic scroll targets exactly one scroller:
553    /// scroll the element cannot consume is discarded rather than transferred to an ancestor.
554    pub fn scroll_to(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
555        self.scroll_programmatically(node_id, ScrollAmount::To(Point { x, y }), behavior);
556    }
557
558    /// Scroll an element by the given relative offset in CSS pixels.
559    pub fn scroll_by(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
560        self.scroll_programmatically(node_id, ScrollAmount::By(Point { x, y }), behavior);
561    }
562
563    fn scroll_programmatically(
564        &mut self,
565        node_id: NodeId,
566        amount: ScrollAmount,
567        behavior: ScrollBehavior,
568    ) {
569        if self.nodes.get(node_id).is_none() {
570            return;
571        }
572
573        // TODO: dispatch `scroll` events for programmatic scrolls.
574        self.scroll(
575            ScrollRequest {
576                target: ScrollTarget::Node(node_id),
577                amount,
578                overflow: ScrollOverflow::Clamp,
579                source: ScrollSource::Programmatic,
580                behavior,
581                interrupt_animation: true,
582            },
583            &mut |_| {},
584        );
585    }
586
587    fn aligned_scroll_offset(
588        current: f64,
589        viewport_size: f64,
590        target_start: f64,
591        target_size: f64,
592        position: ScrollLogicalPosition,
593    ) -> f64 {
594        let target_end = target_start + target_size;
595        match position {
596            ScrollLogicalPosition::Start => target_start,
597            ScrollLogicalPosition::Center => target_start - (viewport_size - target_size) / 2.0,
598            ScrollLogicalPosition::End => target_end - viewport_size,
599            ScrollLogicalPosition::Nearest => {
600                let viewport_end = current + viewport_size;
601                if (target_start >= current && target_end <= viewport_end)
602                    || (target_start <= current && target_end >= viewport_end)
603                {
604                    current
605                } else {
606                    let start_offset = target_start;
607                    let end_offset = target_end - viewport_size;
608                    if (start_offset - current).abs() < (end_offset - current).abs() {
609                        start_offset
610                    } else {
611                        end_offset
612                    }
613                }
614            }
615        }
616    }
617
618    /// Scroll the viewport so that the given element has the requested alignment in each axis.
619    pub fn scroll_into_view(
620        &mut self,
621        node_id: NodeId,
622        behavior: ScrollBehavior,
623        vertical: ScrollLogicalPosition,
624        horizontal: ScrollLogicalPosition,
625    ) {
626        let Some(node) = self.nodes.get(node_id) else {
627            return;
628        };
629        let target =
630            node.absolute_position(node.scroll_offset().x as f32, node.scroll_offset().y as f32);
631        let target_size = node.final_layout().size;
632        let Some(root_id) = self.try_root_element().map(|root| root.id) else {
633            return;
634        };
635        let scale = self.viewport.scale() as f64;
636        let viewport_width = self.viewport.window_size.0 as f64 / scale;
637        let viewport_height = self.viewport.window_size.1 as f64 / scale;
638        let x = Self::aligned_scroll_offset(
639            self.viewport_scroll.x,
640            viewport_width,
641            target.x as f64,
642            target_size.width as f64,
643            horizontal,
644        );
645        let y = Self::aligned_scroll_offset(
646            self.viewport_scroll.y,
647            viewport_height,
648            target.y as f64,
649            target_size.height as f64,
650            vertical,
651        );
652        self.scroll_to(root_id, x, y, behavior);
653    }
654
655    /// Resolve a URL fragment (the `#...` part of a URL) to a scroll target.
656    ///
657    /// Returns `None` if the fragment matches no element and is not a top-of-document
658    /// fragment. Otherwise returns `Some(target)`, where `target` is `Some(node_id)` for
659    /// the element to scroll to, or `None` to scroll to the top of the document (matching
660    /// browser behaviour for empty and `top` fragments).
661    fn resolve_fragment_scroll_target(&self, fragment: &str) -> Option<Option<NodeId>> {
662        // Fragments are percent-encoded in URLs (e.g. `%20`); decode before matching.
663        let decoded = percent_encoding::percent_decode_str(fragment)
664            .decode_utf8_lossy()
665            .into_owned();
666
667        if !decoded.is_empty() {
668            if let Some(node_id) = self.get_fragment_target(&decoded) {
669                return Some(Some(node_id));
670            }
671        }
672
673        // An empty fragment, or the special "top" fragment when no matching element exists,
674        // scrolls to the top of the document.
675        if decoded.is_empty() || decoded.eq_ignore_ascii_case("top") {
676            return Some(None);
677        }
678
679        None
680    }
681
682    fn scroll_to_fragment_with_behavior(
683        &mut self,
684        fragment: &str,
685        behavior: ScrollBehavior,
686    ) -> bool {
687        match self.resolve_fragment_scroll_target(fragment) {
688            Some(Some(node_id)) => {
689                self.scroll_into_view(
690                    node_id,
691                    behavior,
692                    ScrollLogicalPosition::Start,
693                    ScrollLogicalPosition::Nearest,
694                );
695                true
696            }
697            Some(None) => {
698                let root_id = self.root_element().id;
699                self.scroll_to(root_id, 0.0, 0.0, behavior);
700                true
701            }
702            None => false,
703        }
704    }
705
706    /// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
707    ///
708    /// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
709    /// of the document, matching browser behaviour. Returns `true` if a scroll target was
710    /// found.
711    pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
712        self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
713    }
714
715    /// Like [`BaseDocument::scroll_to_fragment`], but animates the viewport towards the
716    /// target instead of jumping instantly. Returns `true` if a scroll target was found.
717    pub fn scroll_to_fragment_smooth(&mut self, fragment: &str) -> bool {
718        self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Smooth)
719    }
720
721    pub fn resolve_scroll_animation(&mut self) {
722        match &mut self.scroll_animation {
723            ScrollAnimationState::Fling(fling_state) => {
724                let time_ms = SystemTime::now()
725                    .duration_since(UNIX_EPOCH)
726                    .unwrap()
727                    .as_millis() as u64 as f64;
728
729                let time_diff_ms = time_ms - fling_state.last_seen_time;
730
731                // 0.95 @ 60fps normalized to actual frame times
732                let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
733
734                fling_state.x_velocity *= deceleration;
735                fling_state.y_velocity *= deceleration;
736                fling_state.last_seen_time = time_ms;
737                let fling_state = fling_state.clone();
738
739                let dx = fling_state.x_velocity * time_diff_ms;
740                let dy = fling_state.y_velocity * time_diff_ms;
741
742                self.scroll_chain_by(Some(fling_state.target), dx, dy, &mut |_| {});
743                if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
744                    self.scroll_animation = ScrollAnimationState::None;
745                }
746            }
747            ScrollAnimationState::ScrollTo(scroll_to) => {
748                let scroll_to = scroll_to.clone();
749                let time_ms = SystemTime::now()
750                    .duration_since(UNIX_EPOCH)
751                    .unwrap()
752                    .as_millis() as u64 as f64;
753
754                // Normalised progress through the animation, clamped to [0, 1].
755                let progress = if scroll_to.duration <= 0.0 {
756                    1.0
757                } else {
758                    ((time_ms - scroll_to.start_time) / scroll_to.duration).clamp(0.0, 1.0)
759                };
760                let eased = ease_in_out_cubic(progress);
761
762                // Interpolate the target offset and move to it.
763                let target = Point {
764                    x: scroll_to.start.x + (scroll_to.end.x - scroll_to.start.x) * eased,
765                    y: scroll_to.start.y + (scroll_to.end.y - scroll_to.start.y) * eased,
766                };
767                // TODO: dispatch `scroll` events for programmatic scrolls.
768                self.write_scroll_offset(scroll_to.target, target, &mut |_| {});
769
770                if progress >= 1.0 {
771                    self.scroll_animation = ScrollAnimationState::None;
772                }
773            }
774            ScrollAnimationState::None => {
775                // Do nothing
776            }
777        }
778    }
779}