Skip to main content

apace_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 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/// 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 an existing smooth scroll, or a touch fling when the
77    /// request is programmatic. Fling frames use the user scroll chain themselves.
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                || request.source == ScrollSource::Programmatic)
139        {
140            self.scroll_animation = ScrollAnimationState::None;
141        }
142
143        let target = self.canonical_scroll_target(request.target);
144
145        // Text inputs and sub-documents scroll their own content rather than an overflow
146        // scrollport, so they only take part in the chained (user-initiated) path.
147        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        // Transfer the delta the target could not consume to the next scroller in the chain.
179        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    /// Scroll a node which scrolls content of its own rather than an overflow scrollport
200    /// (a text input or a sub-document), returning `None` if the node is not such a node.
201    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            // TODO: propagate the remaining scroll to the outer document
215            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        // Single-line inputs scroll their text horizontally, multi-line inputs vertically.
224        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        // `TextInputData::scroll_by` takes (and returns the unconsumed part of) a delta in
233        // the opposite direction to a scroll offset.
234        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    /// The scroller which unconsumed scroll is transferred to: the node's parent, or the
260    /// viewport once the chain runs out of nodes.
261    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    /// Resolve a scroll target to the scroller which actually moves: the root element scrolls
282    /// the viewport, per the CSS overflow propagation rules.
283    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    /// Write a scroll target's offset (which must already be clamped to its scrollable
295    /// range), dispatching a `scroll` event and returning whether the offset changed.
296    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    /// Scroll a node by given x and y
374    /// Will bubble scrolling up to parent node once it can no longer scroll further
375    /// If we're already at the root node, bubbles scrolling up to the viewport
376    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                // A user-facing scroll delta moves the content, i.e. the opposite direction
387                // to the scroll offset.
388                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    /// Scroll the viewport by the given values
403    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                // A user-initiated scroll aborts any smooth scroll in progress, so that the
435                // two do not fight over the scroll offset for the rest of the animation.
436                interrupt_animation: true,
437            },
438            dispatch_event,
439        )
440    }
441
442    /// Duration (in milliseconds) of an animated scroll.
443    const SMOOTH_SCROLL_DURATION_MS: f64 = 300.0;
444
445    /// Returns the current scroll offset and the maximum scroll offset (the minimum is
446    /// always `0`) for the given scroll target. `include_hidden` controls whether
447    /// `overflow: hidden` axes count as scrollable (they do for programmatic scrolls,
448    /// which apply to any scrolling box, but not for user scrolls).
449    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                // The viewport scrolls the root element's scrollable overflow, which includes
457                // both the root element itself and any content which overflows it (e.g. when
458                // the root element has a fixed height but its content is taller). A document
459                // without a root element has no scrollable content.
460                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                // An axis with a non-scrolling overflow value has no scrollable range, even
489                // when its content overflows.
490                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    /// Start a smooth (animated) scroll towards the given absolute scroll offset. The
520    /// animation is advanced each frame in [`BaseDocument::resolve_scroll_animation`].
521    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        // Ensure the frame loop runs so the animation is driven to completion.
538        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    /// Scroll an element to the given absolute scroll offset in CSS pixels.
561    ///
562    /// Unlike a user-initiated scroll, a programmatic scroll targets exactly one scroller:
563    /// scroll the element cannot consume is discarded rather than transferred to an ancestor.
564    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    /// Scroll an element by the given relative offset in CSS pixels.
569    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        // TODO: dispatch `scroll` events for programmatic scrolls.
584        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    /// Scroll the viewport so that the given element has the requested alignment in each axis.
629    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    /// Resolve a URL fragment (the `#...` part of a URL) to a scroll target.
666    ///
667    /// Returns `None` if the fragment matches no element and is not a top-of-document
668    /// fragment. Otherwise returns `Some(target)`, where `target` is `Some(node_id)` for
669    /// the element to scroll to, or `None` to scroll to the top of the document (matching
670    /// browser behaviour for empty and `top` fragments).
671    fn resolve_fragment_scroll_target(&self, fragment: &str) -> Option<Option<NodeId>> {
672        // Fragments are percent-encoded in URLs (e.g. `%20`); decode before matching.
673        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        // An empty fragment, or the special "top" fragment when no matching element exists,
684        // scrolls to the top of the document.
685        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    /// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
717    ///
718    /// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
719    /// of the document, matching browser behaviour. Returns `true` if a scroll target was
720    /// found.
721    pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
722        self.scroll_to_fragment_with_behavior(fragment, ScrollBehavior::Auto)
723    }
724
725    /// Like [`BaseDocument::scroll_to_fragment`], but animates the viewport towards the
726    /// target instead of jumping instantly. Returns `true` if a scroll target was found.
727    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                // 0.95 @ 60fps normalized to actual frame times
742                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                // Normalised progress through the animation, clamped to [0, 1].
765                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                // Interpolate the target offset and move to it.
773                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                // TODO: dispatch `scroll` events for programmatic scrolls.
778                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                // Do nothing
786            }
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}