Skip to main content

layout/display_list/
hit_test.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::sync::Arc;
6
7use app_units::Au;
8use embedder_traits::Cursor;
9use euclid::{Box2D, Point2D, Vector2D};
10use kurbo::{Ellipse, Shape};
11use layout_api::{HitTestFlags, HitTestResult, HitTestResultItem};
12use rustc_hash::FxHashMap;
13use servo_base::id::ScrollTreeNodeId;
14use servo_base::text::{Utf32CodeUnits, Utf32CodeUnitsOrNodeOffset};
15use servo_geometry::FastLayoutTransform;
16use style::computed_values::backface_visibility::T as BackfaceVisibility;
17use style::computed_values::pointer_events::T as PointerEvents;
18use style::computed_values::visibility::T as Visibility;
19use style::dom::OpaqueNode;
20use style::properties::ComputedValues;
21use style::values::computed::ui::CursorKind;
22use style::values::computed::{Inert, Interactivity};
23use style_traits::CSSPixel;
24use webrender_api::BorderRadius;
25use webrender_api::units::{LayoutPoint, LayoutRect, LayoutSize, RectExt};
26
27use crate::display_list::clip::{Clip, ClipId};
28use crate::display_list::paint_traversal::{PaintTraversal, PaintTraversalHandler};
29use crate::display_list::{StackingContext, StackingContextTree, ToWebRender, TraversalState};
30use crate::fragment_tree::{BoxFragmentWithStyle, Fragment, FragmentFlags, TextFragment};
31use crate::geom::PhysicalRect;
32
33struct DomPositionCandidate {
34    fragment: Fragment,
35    node: OpaqueNode,
36    point_in_target: Point2D<f32, CSSPixel>,
37}
38
39pub(crate) struct HitTest<'a> {
40    /// The flags which describe how to perform this [`HitTest`]
41    flags: HitTestFlags,
42    /// The point to test for this hit test, relative to the page.
43    point_to_test: LayoutPoint,
44    /// A cached version of [`Self::point_to_test`] projected to a spatial node, to avoid
45    /// doing a lot of matrix math over and over.
46    projected_point_to_test: Option<(ScrollTreeNodeId, LayoutPoint, FastLayoutTransform)>,
47    /// The stacking context tree against which to perform the hit test.
48    stacking_context_tree: &'a StackingContextTree,
49    /// The resulting [`HitTestResultItems`] for this hit test.
50    items: Vec<HitTestResultItem>,
51    /// Candidate for `HitTestResult::dom_position_for_selection`
52    dom_position_candidate: Option<DomPositionCandidate>,
53    /// A cache of hit test results for shared clip nodes.
54    clip_hit_test_results: FxHashMap<ClipId, bool>,
55    /// Collected reference frame clips. For painting, reference frame clips are handled
56    /// by enclosing reference frames in stacking contexts, but we don't have that option
57    /// here, so we must handle them manually.
58    collected_reference_frame_clips: Vec<ClipId>,
59}
60
61impl<'a> HitTest<'a> {
62    pub(crate) fn run(
63        flags: HitTestFlags,
64        stacking_context_tree: &'a StackingContextTree,
65        point_to_test: LayoutPoint,
66    ) -> HitTestResult {
67        let mut hit_test = Self {
68            flags,
69            point_to_test,
70            projected_point_to_test: None,
71            stacking_context_tree,
72            items: Vec::new(),
73            dom_position_candidate: None,
74            clip_hit_test_results: FxHashMap::default(),
75            collected_reference_frame_clips: Default::default(),
76        };
77
78        PaintTraversal::traverse(&stacking_context_tree.root_stacking_context, &mut hit_test);
79
80        // PaintTraversal::traverse walks forward through all fragments via the stacking
81        // context tree, so results will be in back-to-front order. We want results to be
82        // front-to-back order, so reverse them.
83        //
84        // TODO: Eventually PaintTraversal should support walking backward through
85        // fragments.
86        hit_test.items.reverse();
87
88        let dom_position_for_selection = if flags.contains(HitTestFlags::IncludeDomPosition) {
89            hit_test
90                .dom_position()
91                .map(|(node, offset)| (node, Utf32CodeUnitsOrNodeOffset(offset.0)))
92        } else {
93            None
94        };
95
96        HitTestResult {
97            dom_position_for_selection,
98            items: hit_test.items,
99        }
100    }
101
102    fn dom_position(&self) -> Option<(OpaqueNode, Utf32CodeUnits)> {
103        let hit = self.dom_position_candidate.as_ref()?;
104        if let Fragment::Text(text_fragment) = &hit.fragment {
105            let character_offset =
106                text_fragment.character_offset(hit.point_in_target.map(Au::from_f32_px))?;
107            return Some((hit.node, character_offset));
108        }
109
110        let mut search = ClosestFragmentSearch::default();
111        if let Some(point_in_fragment) = self.stacking_context_tree.offset_in_fragment(
112            &hit.fragment,
113            self.point_to_test.map(Au::from_f32_px).cast_unit(),
114        ) {
115            search.collect_relevant_children(&hit.fragment, point_in_fragment);
116        }
117        search.into_dom_position()
118    }
119
120    /// Perform a hit test against the clip node for the given [`ClipId`], returning
121    /// true if it is not clipped out or false if is clipped out.
122    fn hit_test_clip_id(&mut self, clip_id: ClipId) -> bool {
123        // Using the index here is necessary to avoid a double borrow of `self`.
124        for index in 0..self.collected_reference_frame_clips.len() {
125            if !self.hit_test_individual_clip_id(self.collected_reference_frame_clips[index]) {
126                return false;
127            }
128        }
129        self.hit_test_individual_clip_id(clip_id)
130    }
131
132    fn hit_test_individual_clip_id(&mut self, clip_id: ClipId) -> bool {
133        if clip_id == ClipId::INVALID {
134            return true;
135        }
136
137        if let Some(result) = self.clip_hit_test_results.get(&clip_id) {
138            return *result;
139        }
140
141        let clip = self.stacking_context_tree.clip_store.get(clip_id);
142        let result = self
143            .location_in_spatial_node(clip.parent_scroll_node_id)
144            .is_some_and(|(point, _)| {
145                clip.contains(point) && self.hit_test_individual_clip_id(clip.parent_clip_id)
146            });
147        self.clip_hit_test_results.insert(clip_id, result);
148        result
149    }
150
151    /// Get the hit test location in the coordinate system of the given spatial node,
152    /// returning `None` if the transformation is uninvertible or the point cannot be
153    /// projected into the spatial node.
154    fn location_in_spatial_node(
155        &mut self,
156        scroll_tree_node_id: ScrollTreeNodeId,
157    ) -> Option<(LayoutPoint, FastLayoutTransform)> {
158        match self.projected_point_to_test {
159            Some((cached_scroll_tree_node_id, projected_point, transform))
160                if cached_scroll_tree_node_id == scroll_tree_node_id =>
161            {
162                return Some((projected_point, transform));
163            },
164            _ => {},
165        }
166
167        let transform = self
168            .stacking_context_tree
169            .paint_info
170            .scroll_tree
171            .cumulative_root_to_node_transform(scroll_tree_node_id)?;
172
173        let projected_point = transform.project_point2d(self.point_to_test)?;
174
175        self.projected_point_to_test = Some((scroll_tree_node_id, projected_point, transform));
176        Some((projected_point, transform))
177    }
178}
179
180impl PaintTraversalHandler for HitTest<'_> {
181    /// `true` if we pushed a reference frame clip and `false` otherwise.
182    type StackingContextState = bool;
183
184    fn visit_stacking_context(
185        &mut self,
186        stacking_context: &StackingContext,
187    ) -> Self::StackingContextState {
188        if let Some(reference_frame_info) = stacking_context.reference_frame_info.as_ref() &&
189            reference_frame_info.captured_clip_id != ClipId::INVALID
190        {
191            self.collected_reference_frame_clips
192                .push(reference_frame_info.captured_clip_id);
193            return true;
194        }
195        false
196    }
197
198    fn leave_stacking_context(
199        &mut self,
200        _: &TraversalState,
201        pushed_reference_frame_clip: Self::StackingContextState,
202    ) {
203        if pushed_reference_frame_clip {
204            self.collected_reference_frame_clips.pop();
205        }
206    }
207
208    fn visit_box(&mut self, state: &TraversalState, fragment: &BoxFragmentWithStyle<'_>) {
209        Fragment::Box(fragment.box_fragment.clone()).hit_test(state, self);
210    }
211
212    fn visit_text(
213        &mut self,
214        state: &TraversalState,
215        _line_box_rect: PhysicalRect<Au>,
216        fragment: &Arc<TextFragment>,
217    ) {
218        Fragment::Text(fragment.clone()).hit_test(state, self);
219    }
220}
221
222impl Clip {
223    fn contains(&self, point: LayoutPoint) -> bool {
224        rounded_rect_contains_point(self.rect, &self.radii, point)
225    }
226}
227
228impl Fragment {
229    pub(crate) fn hit_test(&self, state: &TraversalState, hit_test: &mut HitTest) -> bool {
230        let Some(tag) = self.tag() else {
231            return false;
232        };
233        if !hit_test.hit_test_clip_id(state.clip_id) {
234            return false;
235        }
236
237        let mut hit_test_fragment_inner =
238            |style: &ComputedValues,
239             fragment_rect: PhysicalRect<Au>,
240             border_radius: BorderRadius,
241             fragment_flags: FragmentFlags,
242             auto_cursor: Cursor| {
243                let is_root_element = fragment_flags.contains(FragmentFlags::IS_ROOT_ELEMENT);
244
245                if !is_root_element {
246                    // <https://drafts.csswg.org/css-ui-4/#inertness>
247                    // > Hit-testing must act as if the pointer-events property were set to none.
248                    let inherited_ui = style.get_inherited_ui();
249                    if inherited_ui.pointer_events == PointerEvents::None ||
250                        inherited_ui._moz_inert == Inert::Inert ||
251                        inherited_ui.interactivity == Interactivity::Inert
252                    {
253                        return false;
254                    }
255                    if style.get_inherited_box().visibility != Visibility::Visible {
256                        return false;
257                    }
258                }
259
260                let (point_in_spatial_node, transform) =
261                    match hit_test.location_in_spatial_node(state.spatial_id) {
262                        Some(point) => point,
263                        None => return false,
264                    };
265
266                if !is_root_element &&
267                    style.get_box().backface_visibility == BackfaceVisibility::Hidden &&
268                    transform.is_backface_visible()
269                {
270                    return false;
271                }
272
273                let fragment_rect = fragment_rect.translate(state.origin.to_vector());
274                if is_root_element {
275                    let viewport_size = hit_test
276                        .stacking_context_tree
277                        .paint_info
278                        .viewport_details
279                        .size;
280                    let viewport_rect = LayoutRect::from_origin_and_size(
281                        Default::default(),
282                        viewport_size.cast_unit(),
283                    );
284                    if !viewport_rect.contains(hit_test.point_to_test) {
285                        return false;
286                    }
287                } else if !rounded_rect_contains_point(
288                    fragment_rect.to_webrender(),
289                    &border_radius,
290                    point_in_spatial_node,
291                ) {
292                    return false;
293                }
294
295                let point_in_target = point_in_spatial_node.cast_unit() -
296                    Vector2D::new(
297                        fragment_rect.origin.x.to_f32_px(),
298                        fragment_rect.origin.y.to_f32_px(),
299                    );
300
301                hit_test.items.push(HitTestResultItem {
302                    node: tag.node,
303                    point_in_target,
304                    cursor: cursor(style.get_inherited_ui().cursor.keyword, auto_cursor),
305                });
306
307                // Selection boundaries cannot intersect generated content, which is what
308                // the pseudo_element_chain check does here.
309                if hit_test.flags.intersects(HitTestFlags::IncludeDomPosition) &&
310                    tag.pseudo_element_chain.is_empty()
311                {
312                    hit_test.dom_position_candidate = Some(DomPositionCandidate {
313                        fragment: self.clone(),
314                        node: tag.node,
315                        point_in_target,
316                    });
317                }
318
319                // Since there is no reverse PaintTraversal, hit testing always searches
320                // the entire fragment tree (in stacking context order), which is why this
321                // is always returning `false` (keep looking). Once PaintTraversal can
322                // walk backward through fragments, this can return `true` if FindAll
323                // isn't specified.
324                false
325            };
326
327        match self {
328            Fragment::LayoutRoot(layout_root_fragment) => {
329                layout_root_fragment.inner().hit_test(state, hit_test)
330            },
331            Fragment::Box(box_fragment) | Fragment::Float(box_fragment) => hit_test_fragment_inner(
332                &box_fragment.style(),
333                box_fragment.border_rect(),
334                box_fragment.border_radius(),
335                box_fragment.base.flags,
336                Cursor::Default,
337            ),
338            Fragment::Text(text) => hit_test_fragment_inner(
339                &text.style(),
340                text.base.rect(),
341                BorderRadius::zero(),
342                FragmentFlags::empty(),
343                Cursor::Text,
344            ),
345            _ => false,
346        }
347    }
348}
349
350fn rounded_rect_contains_point(
351    rect: LayoutRect,
352    border_radius: &BorderRadius,
353    point: LayoutPoint,
354) -> bool {
355    if !rect.contains(point) {
356        return false;
357    }
358
359    if border_radius.is_zero() {
360        return true;
361    }
362
363    let check_corner = |corner: LayoutPoint, radius: &LayoutSize, is_right, is_bottom| {
364        let mut origin = corner;
365        if is_right {
366            origin.x -= radius.width;
367        }
368        if is_bottom {
369            origin.y -= radius.height;
370        }
371        if !Box2D::from_origin_and_size(origin, *radius).contains(point) {
372            return true;
373        }
374        let center = (
375            if is_right {
376                corner.x - radius.width
377            } else {
378                corner.x + radius.width
379            },
380            if is_bottom {
381                corner.y - radius.height
382            } else {
383                corner.y + radius.height
384            },
385        );
386        let radius = (radius.width as f64, radius.height as f64);
387        Ellipse::new(center, radius, 0.0).contains((point.x, point.y).into())
388    };
389
390    check_corner(rect.top_left(), &border_radius.top_left, false, false) &&
391        check_corner(rect.top_right(), &border_radius.top_right, true, false) &&
392        check_corner(rect.bottom_right(), &border_radius.bottom_right, true, true) &&
393        check_corner(rect.bottom_left(), &border_radius.bottom_left, false, true)
394}
395
396fn cursor(kind: CursorKind, auto_cursor: Cursor) -> Cursor {
397    match kind {
398        CursorKind::Auto => auto_cursor,
399        CursorKind::None => Cursor::None,
400        CursorKind::Default => Cursor::Default,
401        CursorKind::Pointer => Cursor::Pointer,
402        CursorKind::ContextMenu => Cursor::ContextMenu,
403        CursorKind::Help => Cursor::Help,
404        CursorKind::Progress => Cursor::Progress,
405        CursorKind::Wait => Cursor::Wait,
406        CursorKind::Cell => Cursor::Cell,
407        CursorKind::Crosshair => Cursor::Crosshair,
408        CursorKind::Text => Cursor::Text,
409        CursorKind::VerticalText => Cursor::VerticalText,
410        CursorKind::Alias => Cursor::Alias,
411        CursorKind::Copy => Cursor::Copy,
412        CursorKind::Move => Cursor::Move,
413        CursorKind::NoDrop => Cursor::NoDrop,
414        CursorKind::NotAllowed => Cursor::NotAllowed,
415        CursorKind::Grab => Cursor::Grab,
416        CursorKind::Grabbing => Cursor::Grabbing,
417        CursorKind::EResize => Cursor::EResize,
418        CursorKind::NResize => Cursor::NResize,
419        CursorKind::NeResize => Cursor::NeResize,
420        CursorKind::NwResize => Cursor::NwResize,
421        CursorKind::SResize => Cursor::SResize,
422        CursorKind::SeResize => Cursor::SeResize,
423        CursorKind::SwResize => Cursor::SwResize,
424        CursorKind::WResize => Cursor::WResize,
425        CursorKind::EwResize => Cursor::EwResize,
426        CursorKind::NsResize => Cursor::NsResize,
427        CursorKind::NeswResize => Cursor::NeswResize,
428        CursorKind::NwseResize => Cursor::NwseResize,
429        CursorKind::ColResize => Cursor::ColResize,
430        CursorKind::RowResize => Cursor::RowResize,
431        CursorKind::AllScroll => Cursor::AllScroll,
432        CursorKind::ZoomIn => Cursor::ZoomIn,
433        CursorKind::ZoomOut => Cursor::ZoomOut,
434    }
435}
436
437pub(crate) struct ClosestFragment {
438    fragment: Arc<TextFragment>,
439    node: OpaqueNode,
440    point_in_fragment: Point2D<Au, CSSPixel>,
441    distance: Au,
442    point_in_vertical_bounds: bool,
443}
444
445impl ClosestFragment {
446    fn should_replace(&self, new_distance: Au, point_in_vertical_bounds: bool) -> bool {
447        if point_in_vertical_bounds && !self.point_in_vertical_bounds {
448            return true;
449        }
450        if self.point_in_vertical_bounds && !point_in_vertical_bounds {
451            return false;
452        }
453        new_distance <= self.distance
454    }
455
456    pub(crate) fn dom_position(&self) -> Option<(OpaqueNode, Utf32CodeUnits)> {
457        let character_offset = self.fragment.character_offset(self.point_in_fragment)?;
458        Some((self.node, character_offset))
459    }
460}
461
462#[derive(Default)]
463pub(crate) struct ClosestFragmentSearch {
464    closest: Option<ClosestFragment>,
465}
466
467impl ClosestFragmentSearch {
468    pub(crate) fn into_dom_position(self) -> Option<(OpaqueNode, Utf32CodeUnits)> {
469        self.closest?.dom_position()
470    }
471
472    fn maybe_update(&mut self, fragment: &Fragment, point_in_fragment: Point2D<Au, CSSPixel>) {
473        let Fragment::Text(text_fragment) = fragment else {
474            return;
475        };
476
477        let (distance, point_in_vertical_bounds) = {
478            (
479                text_fragment.distance_to_point_for_glyph_offset(point_in_fragment),
480                text_fragment.point_is_within_vertical_boundaries(point_in_fragment),
481            )
482        };
483
484        if let Some(tag) = text_fragment.base.tag.as_ref() &&
485            tag.pseudo_element_chain.is_empty() &&
486            self.closest.as_ref().is_none_or(|closest_fragment| {
487                closest_fragment.should_replace(distance, point_in_vertical_bounds)
488            })
489        {
490            self.closest = Some(ClosestFragment {
491                fragment: text_fragment.clone(),
492                node: tag.node,
493                point_in_fragment,
494                distance,
495                point_in_vertical_bounds,
496            });
497        }
498    }
499
500    pub(crate) fn collect_relevant_children(
501        &mut self,
502        fragment: &Fragment,
503        point_in_fragment: Point2D<Au, CSSPixel>,
504    ) {
505        self.maybe_update(fragment, point_in_fragment);
506        if let Some(children) = fragment.children() {
507            for child in children.iter() {
508                let offset = child
509                    .base()
510                    .map(|base| base.rect().origin)
511                    .unwrap_or_default();
512                let point = point_in_fragment - offset.to_vector();
513                self.collect_relevant_children(child, point);
514            }
515        }
516    }
517}