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.set_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                self.nodes.bump_geometry_generation();
337                let event = BlitzScrollEvent {
338                    scroll_top: offset.y,
339                    scroll_left: offset.x,
340                    scroll_width: layout.scroll_width() as i32,
341                    scroll_height: layout.scroll_height() as i32,
342                    client_width: layout.size.width as i32,
343                    client_height: layout.size.height as i32,
344                };
345                dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
346
347                self.show_scrollbars(node_id);
348                self.shell_provider.request_redraw();
349                true
350            }
351        }
352    }
353
354    pub fn scroll_node_by<F: FnMut(DomEvent)>(
355        &mut self,
356        node_id: NodeId,
357        x: f64,
358        y: f64,
359        dispatch_event: F,
360    ) {
361        self.scroll_node_by_has_changed(node_id, x, y, dispatch_event);
362    }
363
364    /// Scroll a node by given x and y
365    /// Will bubble scrolling up to parent node once it can no longer scroll further
366    /// If we're already at the root node, bubbles scrolling up to the viewport
367    pub fn scroll_node_by_has_changed<F: FnMut(DomEvent)>(
368        &mut self,
369        node_id: NodeId,
370        x: f64,
371        y: f64,
372        mut dispatch_event: F,
373    ) -> bool {
374        self.scroll(
375            ScrollRequest {
376                target: ScrollTarget::Node(node_id),
377                // A user-facing scroll delta moves the content, i.e. the opposite direction
378                // to the scroll offset.
379                amount: ScrollAmount::By(Point { x: -x, y: -y }),
380                overflow: ScrollOverflow::Chain,
381                source: ScrollSource::User,
382                behavior: ScrollBehavior::Instant,
383                interrupt_animation: false,
384            },
385            &mut dispatch_event,
386        )
387    }
388
389    pub fn scroll_viewport_by(&mut self, x: f64, y: f64) {
390        self.scroll_viewport_by_has_changed(x, y);
391    }
392
393    /// Scroll the viewport by the given values
394    pub fn scroll_viewport_by_has_changed(&mut self, x: f64, y: f64) -> bool {
395        self.scroll(
396            ScrollRequest {
397                target: ScrollTarget::Viewport,
398                amount: ScrollAmount::By(Point { x: -x, y: -y }),
399                overflow: ScrollOverflow::Clamp,
400                source: ScrollSource::User,
401                behavior: ScrollBehavior::Instant,
402                interrupt_animation: false,
403            },
404            &mut |_| {},
405        )
406    }
407
408    pub(crate) fn scroll_chain_by(
409        &mut self,
410        anchor_node_id: Option<NodeId>,
411        scroll_x: f64,
412        scroll_y: f64,
413        dispatch_event: &mut dyn FnMut(DomEvent),
414    ) -> bool {
415        self.scroll(
416            ScrollRequest {
417                target: anchor_node_id.map_or(ScrollTarget::Viewport, ScrollTarget::Node),
418                amount: ScrollAmount::By(Point {
419                    x: -scroll_x,
420                    y: -scroll_y,
421                }),
422                overflow: ScrollOverflow::Chain,
423                source: ScrollSource::User,
424                behavior: ScrollBehavior::Instant,
425                // A user-initiated scroll aborts any smooth scroll in progress, so that the
426                // two do not fight over the scroll offset for the rest of the animation.
427                interrupt_animation: true,
428            },
429            dispatch_event,
430        )
431    }
432
433    /// Duration (in milliseconds) of an animated scroll.
434    const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
435
436    /// Returns the current scroll offset and the maximum scroll offset (the minimum is
437    /// always `0`) for the given scroll target. `include_hidden` controls whether
438    /// `overflow: hidden` axes count as scrollable (they do for programmatic scrolls,
439    /// which apply to any scrolling box, but not for user scrolls).
440    pub(crate) fn scroll_state(
441        &self,
442        target: ScrollTarget,
443        include_hidden: bool,
444    ) -> (Point<f64>, Point<f64>) {
445        match self.canonical_scroll_target(target) {
446            ScrollTarget::Viewport => {
447                // The viewport scrolls the root element's scrollable overflow, which includes
448                // both the root element itself and any content which overflows it (e.g. when
449                // the root element has a fixed height but its content is taller). A document
450                // without a root element has no scrollable content.
451                let (content_width, content_height) = match self.try_root_element() {
452                    Some(root) => {
453                        let layout = root.final_layout();
454                        (
455                            layout.size.width.max(layout.scrollable_overflow_rect.right) as f64,
456                            layout
457                                .size
458                                .height
459                                .max(layout.scrollable_overflow_rect.bottom)
460                                as f64,
461                        )
462                    }
463                    None => (0.0, 0.0),
464                };
465                let scale = self.viewport.scale() as f64;
466                let window_width = self.viewport.window_size.0 as f64 / scale;
467                let window_height = self.viewport.window_size.1 as f64 / scale;
468                let max = Point {
469                    x: (content_width - window_width).max(0.0),
470                    y: (content_height - window_height).max(0.0),
471                };
472                (self.viewport_scroll(), max)
473            }
474            ScrollTarget::Node(node_id) => {
475                let Some(node) = self.nodes.get(node_id) else {
476                    return (Point::ZERO, Point::ZERO);
477                };
478
479                // An axis with a non-scrolling overflow value has no scrollable range, even
480                // when its content overflows.
481                let scrollable = |overflow| match overflow {
482                    Overflow::Scroll | Overflow::Auto => true,
483                    Overflow::Hidden => include_hidden,
484                    _ => false,
485                };
486                let (can_x_scroll, can_y_scroll) = node
487                    .primary_styles()
488                    .map(|styles| {
489                        (
490                            scrollable(styles.clone_overflow_x()),
491                            scrollable(styles.clone_overflow_y()),
492                        )
493                    })
494                    .unwrap_or((false, false));
495                let max = Point {
496                    x: match can_x_scroll {
497                        true => node.final_layout().scroll_width() as f64,
498                        false => 0.0,
499                    },
500                    y: match can_y_scroll {
501                        true => node.final_layout().scroll_height() as f64,
502                        false => 0.0,
503                    },
504                };
505                (*node.scroll_offset(), max)
506            }
507        }
508    }
509
510    /// Start a smooth (animated) scroll towards the given absolute scroll offset. The
511    /// animation is advanced each frame in [`BaseDocument::resolve_scroll_animation`].
512    fn start_scroll_animation(&mut self, target: ScrollTarget, end: Point<f64>) {
513        let start = self.scroll_state(target, true).0;
514
515        let start_time = SystemTime::now()
516            .duration_since(UNIX_EPOCH)
517            .unwrap()
518            .as_millis() as u64 as f64;
519
520        self.scroll_animation = ScrollAnimationState::ScrollTo(ScrollToState {
521            target,
522            start,
523            end,
524            start_time,
525            duration: Self::SMOOTH_SCROLL_DURATION_MS,
526        });
527
528        // Ensure the frame loop runs so the animation is driven to completion.
529        self.shell_provider.request_redraw();
530    }
531
532    fn should_scroll_smoothly(&self, target: ScrollTarget, behavior: ScrollBehavior) -> bool {
533        match behavior {
534            ScrollBehavior::Auto => {
535                let styled_node = match target {
536                    ScrollTarget::Node(node_id) => self.nodes.get(node_id),
537                    ScrollTarget::Viewport => self.try_root_element(),
538                };
539                styled_node.is_some_and(|node| {
540                    node.primary_styles().is_some_and(|style| {
541                        style.clone_scroll_behavior()
542                            == style::computed_values::scroll_behavior::T::Smooth
543                    })
544                })
545            }
546            ScrollBehavior::Instant => false,
547            ScrollBehavior::Smooth => true,
548        }
549    }
550
551    /// Scroll an element to the given absolute scroll offset in CSS pixels.
552    ///
553    /// Unlike a user-initiated scroll, a programmatic scroll targets exactly one scroller:
554    /// scroll the element cannot consume is discarded rather than transferred to an ancestor.
555    pub fn scroll_to(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
556        self.scroll_programmatically(node_id, ScrollAmount::To(Point { x, y }), behavior);
557    }
558
559    /// Scroll an element by the given relative offset in CSS pixels.
560    pub fn scroll_by(&mut self, node_id: NodeId, x: f64, y: f64, behavior: ScrollBehavior) {
561        self.scroll_programmatically(node_id, ScrollAmount::By(Point { x, y }), behavior);
562    }
563
564    fn scroll_programmatically(
565        &mut self,
566        node_id: NodeId,
567        amount: ScrollAmount,
568        behavior: ScrollBehavior,
569    ) {
570        if self.nodes.get(node_id).is_none() {
571            return;
572        }
573
574        // TODO: dispatch `scroll` events for programmatic scrolls.
575        self.scroll(
576            ScrollRequest {
577                target: ScrollTarget::Node(node_id),
578                amount,
579                overflow: ScrollOverflow::Clamp,
580                source: ScrollSource::Programmatic,
581                behavior,
582                interrupt_animation: true,
583            },
584            &mut |_| {},
585        );
586    }
587
588    fn aligned_scroll_offset(
589        current: f64,
590        viewport_size: f64,
591        target_start: f64,
592        target_size: f64,
593        position: ScrollLogicalPosition,
594    ) -> f64 {
595        let target_end = target_start + target_size;
596        match position {
597            ScrollLogicalPosition::Start => target_start,
598            ScrollLogicalPosition::Center => target_start - (viewport_size - target_size) / 2.0,
599            ScrollLogicalPosition::End => target_end - viewport_size,
600            ScrollLogicalPosition::Nearest => {
601                let viewport_end = current + viewport_size;
602                if (target_start >= current && target_end <= viewport_end)
603                    || (target_start <= current && target_end >= viewport_end)
604                {
605                    current
606                } else {
607                    let start_offset = target_start;
608                    let end_offset = target_end - viewport_size;
609                    if (start_offset - current).abs() < (end_offset - current).abs() {
610                        start_offset
611                    } else {
612                        end_offset
613                    }
614                }
615            }
616        }
617    }
618
619    /// Scroll the viewport so that the given element has the requested alignment in each axis.
620    pub fn scroll_into_view(
621        &mut self,
622        node_id: NodeId,
623        behavior: ScrollBehavior,
624        vertical: ScrollLogicalPosition,
625        horizontal: ScrollLogicalPosition,
626    ) {
627        let Some(node) = self.nodes.get(node_id) else {
628            return;
629        };
630        let target =
631            node.absolute_position(node.scroll_offset().x as f32, node.scroll_offset().y as f32);
632        let target_size = node.final_layout().size;
633        let Some(root_id) = self.try_root_element().map(|root| root.id) else {
634            return;
635        };
636        let scale = self.viewport.scale() as f64;
637        let viewport_width = self.viewport.window_size.0 as f64 / scale;
638        let viewport_height = self.viewport.window_size.1 as f64 / scale;
639        let x = Self::aligned_scroll_offset(
640            self.viewport_scroll().x,
641            viewport_width,
642            target.x as f64,
643            target_size.width as f64,
644            horizontal,
645        );
646        let y = Self::aligned_scroll_offset(
647            self.viewport_scroll().y,
648            viewport_height,
649            target.y as f64,
650            target_size.height as f64,
651            vertical,
652        );
653        self.scroll_to(root_id, x, y, behavior);
654    }
655
656    /// Resolve a URL fragment (the `#...` part of a URL) to a scroll target.
657    ///
658    /// Returns `None` if the fragment matches no element and is not a top-of-document
659    /// fragment. Otherwise returns `Some(target)`, where `target` is `Some(node_id)` for
660    /// the element to scroll to, or `None` to scroll to the top of the document (matching
661    /// browser behaviour for empty and `top` fragments).
662    fn resolve_fragment_scroll_target(&self, fragment: &str) -> Option<Option<NodeId>> {
663        // Fragments are percent-encoded in URLs (e.g. `%20`); decode before matching.
664        let decoded = percent_encoding::percent_decode_str(fragment)
665            .decode_utf8_lossy()
666            .into_owned();
667
668        if !decoded.is_empty() {
669            if let Some(node_id) = self.get_fragment_target(&decoded) {
670                return Some(Some(node_id));
671            }
672        }
673
674        // An empty fragment, or the special "top" fragment when no matching element exists,
675        // scrolls to the top of the document.
676        if decoded.is_empty() || decoded.eq_ignore_ascii_case("top") {
677            return Some(None);
678        }
679
680        None
681    }
682
683    fn scroll_to_fragment_with_behavior(
684        &mut self,
685        fragment: &str,
686        behavior: ScrollBehavior,
687    ) -> bool {
688        match self.resolve_fragment_scroll_target(fragment) {
689            Some(Some(node_id)) => {
690                self.scroll_into_view(
691                    node_id,
692                    behavior,
693                    ScrollLogicalPosition::Start,
694                    ScrollLogicalPosition::Nearest,
695                );
696                true
697            }
698            Some(None) => {
699                let root_id = self.root_element().id;
700                self.scroll_to(root_id, 0.0, 0.0, behavior);
701                true
702            }
703            None => false,
704        }
705    }
706
707    /// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
708    ///
709    /// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
710    /// of the document, matching browser behaviour. Returns `true` if a scroll target was
711    /// found.
712    pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
713        self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
714    }
715
716    /// Like [`BaseDocument::scroll_to_fragment`], but animates the viewport towards the
717    /// target instead of jumping instantly. Returns `true` if a scroll target was found.
718    pub fn scroll_to_fragment_smooth(&mut self, fragment: &str) -> bool {
719        self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Smooth)
720    }
721
722    pub fn resolve_scroll_animation(&mut self) {
723        match &mut self.scroll_animation {
724            ScrollAnimationState::Fling(fling_state) => {
725                let time_ms = SystemTime::now()
726                    .duration_since(UNIX_EPOCH)
727                    .unwrap()
728                    .as_millis() as u64 as f64;
729
730                let time_diff_ms = time_ms - fling_state.last_seen_time;
731
732                // 0.95 @ 60fps normalized to actual frame times
733                let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
734
735                fling_state.x_velocity *= deceleration;
736                fling_state.y_velocity *= deceleration;
737                fling_state.last_seen_time = time_ms;
738                let fling_state = fling_state.clone();
739
740                let dx = fling_state.x_velocity * time_diff_ms;
741                let dy = fling_state.y_velocity * time_diff_ms;
742
743                self.scroll_chain_by(Some(fling_state.target), dx, dy, &mut |_| {});
744                if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
745                    self.scroll_animation = ScrollAnimationState::None;
746                }
747            }
748            ScrollAnimationState::ScrollTo(scroll_to) => {
749                let scroll_to = scroll_to.clone();
750                let time_ms = SystemTime::now()
751                    .duration_since(UNIX_EPOCH)
752                    .unwrap()
753                    .as_millis() as u64 as f64;
754
755                // Normalised progress through the animation, clamped to [0, 1].
756                let progress = if scroll_to.duration <= 0.0 {
757                    1.0
758                } else {
759                    ((time_ms - scroll_to.start_time) / scroll_to.duration).clamp(0.0, 1.0)
760                };
761                let eased = ease_in_out_cubic(progress);
762
763                // Interpolate the target offset and move to it.
764                let target = Point {
765                    x: scroll_to.start.x + (scroll_to.end.x - scroll_to.start.x) * eased,
766                    y: scroll_to.start.y + (scroll_to.end.y - scroll_to.start.y) * eased,
767                };
768                // TODO: dispatch `scroll` events for programmatic scrolls.
769                self.write_scroll_offset(scroll_to.target, target, &mut |_| {});
770
771                if progress >= 1.0 {
772                    self.scroll_animation = ScrollAnimationState::None;
773                }
774            }
775            ScrollAnimationState::None => {
776                // Do nothing
777            }
778        }
779    }
780}