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