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, Vector2D};
10use kurbo::{Ellipse, Shape};
11use layout_api::ElementsFromPointResult;
12use rustc_hash::FxHashMap;
13use servo_base::id::ScrollTreeNodeId;
14use servo_geometry::FastLayoutTransform;
15use style::computed_values::backface_visibility::T as BackfaceVisibility;
16use style::computed_values::pointer_events::T as PointerEvents;
17use style::computed_values::visibility::T as Visibility;
18use style::properties::ComputedValues;
19use style::values::computed::ui::CursorKind;
20use webrender_api::BorderRadius;
21use webrender_api::units::{LayoutPoint, LayoutRect, LayoutSize, RectExt};
22
23use crate::display_list::clip::{Clip, ClipId};
24use crate::display_list::paint_traversal::{PaintTraversal, PaintTraversalHandler};
25use crate::display_list::{StackingContext, StackingContextTree, ToWebRender, TraversalState};
26use crate::fragment_tree::{BoxFragmentWithStyle, Fragment, FragmentFlags, TextFragment};
27use crate::geom::PhysicalRect;
28
29pub(crate) struct HitTest<'a> {
30    /// The point to test for this hit test, relative to the page.
31    point_to_test: LayoutPoint,
32    /// A cached version of [`Self::point_to_test`] projected to a spatial node, to avoid
33    /// doing a lot of matrix math over and over.
34    projected_point_to_test: Option<(ScrollTreeNodeId, LayoutPoint, FastLayoutTransform)>,
35    /// The stacking context tree against which to perform the hit test.
36    stacking_context_tree: &'a StackingContextTree,
37    /// The resulting [`HitTestResultItems`] for this hit test.
38    results: Vec<ElementsFromPointResult>,
39    /// A cache of hit test results for shared clip nodes.
40    clip_hit_test_results: FxHashMap<ClipId, bool>,
41    /// Collected reference frame clips. For painting, reference frame clips are handled
42    /// by enclosing reference frames in stacking contexts, but we don't have that option
43    /// here, so we must handle them manually.
44    collected_reference_frame_clips: Vec<ClipId>,
45}
46
47impl<'a> HitTest<'a> {
48    pub(crate) fn run(
49        stacking_context_tree: &'a StackingContextTree,
50        point_to_test: LayoutPoint,
51    ) -> Vec<ElementsFromPointResult> {
52        let mut hit_test = Self {
53            point_to_test,
54            projected_point_to_test: None,
55            stacking_context_tree,
56            results: Vec::new(),
57            clip_hit_test_results: FxHashMap::default(),
58            collected_reference_frame_clips: Default::default(),
59        };
60
61        PaintTraversal::traverse(&stacking_context_tree.root_stacking_context, &mut hit_test);
62
63        // PaintTraversal::traverse walks forward through all fragments via the stacking
64        // context tree, so results will be in back-to-front order. We want results to be
65        // front-to-back order, so reverse them.
66        //
67        // TODO: Eventually PaintTraversal should support walking backward through
68        // fragments.
69        hit_test.results.reverse();
70
71        hit_test.results
72    }
73
74    /// Perform a hit test against the clip node for the given [`ClipId`], returning
75    /// true if it is not clipped out or false if is clipped out.
76    fn hit_test_clip_id(&mut self, clip_id: ClipId) -> bool {
77        // Using the index here is necessary to avoid a double borrow of `self`.
78        for index in 0..self.collected_reference_frame_clips.len() {
79            if !self.hit_test_individual_clip_id(self.collected_reference_frame_clips[index]) {
80                return false;
81            }
82        }
83        self.hit_test_individual_clip_id(clip_id)
84    }
85
86    fn hit_test_individual_clip_id(&mut self, clip_id: ClipId) -> bool {
87        if clip_id == ClipId::INVALID {
88            return true;
89        }
90
91        if let Some(result) = self.clip_hit_test_results.get(&clip_id) {
92            return *result;
93        }
94
95        let clip = self.stacking_context_tree.clip_store.get(clip_id);
96        let result = self
97            .location_in_spatial_node(clip.parent_scroll_node_id)
98            .is_some_and(|(point, _)| {
99                clip.contains(point) && self.hit_test_individual_clip_id(clip.parent_clip_id)
100            });
101        self.clip_hit_test_results.insert(clip_id, result);
102        result
103    }
104
105    /// Get the hit test location in the coordinate system of the given spatial node,
106    /// returning `None` if the transformation is uninvertible or the point cannot be
107    /// projected into the spatial node.
108    fn location_in_spatial_node(
109        &mut self,
110        scroll_tree_node_id: ScrollTreeNodeId,
111    ) -> Option<(LayoutPoint, FastLayoutTransform)> {
112        match self.projected_point_to_test {
113            Some((cached_scroll_tree_node_id, projected_point, transform))
114                if cached_scroll_tree_node_id == scroll_tree_node_id =>
115            {
116                return Some((projected_point, transform));
117            },
118            _ => {},
119        }
120
121        let transform = self
122            .stacking_context_tree
123            .paint_info
124            .scroll_tree
125            .cumulative_root_to_node_transform(scroll_tree_node_id)?;
126
127        let projected_point = transform.project_point2d(self.point_to_test)?;
128
129        self.projected_point_to_test = Some((scroll_tree_node_id, projected_point, transform));
130        Some((projected_point, transform))
131    }
132}
133
134impl PaintTraversalHandler for HitTest<'_> {
135    /// `true` if we pushed a reference frame clip and `false` otherwise.
136    type StackingContextState = bool;
137
138    fn visit_stacking_context(
139        &mut self,
140        stacking_context: &StackingContext,
141    ) -> Self::StackingContextState {
142        if let Some(reference_frame_info) = stacking_context.reference_frame_info.as_ref() &&
143            reference_frame_info.captured_clip_id != ClipId::INVALID
144        {
145            self.collected_reference_frame_clips
146                .push(reference_frame_info.captured_clip_id);
147            return true;
148        }
149        false
150    }
151
152    fn leave_stacking_context(
153        &mut self,
154        _: &TraversalState,
155        pushed_reference_frame_clip: Self::StackingContextState,
156    ) {
157        if pushed_reference_frame_clip {
158            self.collected_reference_frame_clips.pop();
159        }
160    }
161
162    fn visit_box(&mut self, state: &TraversalState, fragment: &BoxFragmentWithStyle<'_>) {
163        Fragment::Box(fragment.box_fragment.clone()).hit_test(state, self);
164    }
165
166    fn visit_text(
167        &mut self,
168        state: &TraversalState,
169        _: PhysicalRect<Au>,
170        fragment: &Arc<TextFragment>,
171    ) {
172        Fragment::Text(fragment.clone()).hit_test(state, self);
173    }
174}
175
176impl Clip {
177    fn contains(&self, point: LayoutPoint) -> bool {
178        rounded_rect_contains_point(self.rect, &self.radii, point)
179    }
180}
181
182impl Fragment {
183    pub(crate) fn hit_test(&self, state: &TraversalState, hit_test: &mut HitTest) -> bool {
184        let Some(tag) = self.tag() else {
185            return false;
186        };
187        if !hit_test.hit_test_clip_id(state.clip_id) {
188            return false;
189        }
190
191        let mut hit_test_fragment_inner =
192            |style: &ComputedValues,
193             fragment_rect: PhysicalRect<Au>,
194             border_radius: BorderRadius,
195             fragment_flags: FragmentFlags,
196             auto_cursor: Cursor| {
197                let is_root_element = fragment_flags.contains(FragmentFlags::IS_ROOT_ELEMENT);
198
199                if !is_root_element {
200                    if style.get_inherited_ui().pointer_events == PointerEvents::None {
201                        return false;
202                    }
203                    if style.get_inherited_box().visibility != Visibility::Visible {
204                        return false;
205                    }
206                }
207
208                let (point_in_spatial_node, transform) =
209                    match hit_test.location_in_spatial_node(state.spatial_id) {
210                        Some(point) => point,
211                        None => return false,
212                    };
213
214                if !is_root_element &&
215                    style.get_box().backface_visibility == BackfaceVisibility::Hidden &&
216                    transform.is_backface_visible()
217                {
218                    return false;
219                }
220
221                let fragment_rect = fragment_rect.translate(state.origin.to_vector());
222                if is_root_element {
223                    let viewport_size = hit_test
224                        .stacking_context_tree
225                        .paint_info
226                        .viewport_details
227                        .size;
228                    let viewport_rect = LayoutRect::from_origin_and_size(
229                        Default::default(),
230                        viewport_size.cast_unit(),
231                    );
232                    if !viewport_rect.contains(hit_test.point_to_test) {
233                        return false;
234                    }
235                } else if !rounded_rect_contains_point(
236                    fragment_rect.to_webrender(),
237                    &border_radius,
238                    point_in_spatial_node,
239                ) {
240                    return false;
241                }
242
243                let point_in_target = point_in_spatial_node.cast_unit() -
244                    Vector2D::new(
245                        fragment_rect.origin.x.to_f32_px(),
246                        fragment_rect.origin.y.to_f32_px(),
247                    );
248
249                hit_test.results.push(ElementsFromPointResult {
250                    node: tag.node,
251                    point_in_target,
252                    cursor: cursor(style.get_inherited_ui().cursor.keyword, auto_cursor),
253                });
254
255                // Since there is no reverse PaintTraversal, hit testing always searches
256                // the entire fragment tree (in stacking context order), which is why this
257                // is always returning `false` (keep looking). Once PaintTraversal can
258                // walk backward through fragments, this can return `true` if FindAll
259                // isn't specified.
260                false
261            };
262
263        match self {
264            Fragment::LayoutRoot(layout_root_fragment) => {
265                layout_root_fragment.inner().hit_test(state, hit_test)
266            },
267            Fragment::Box(box_fragment) | Fragment::Float(box_fragment) => hit_test_fragment_inner(
268                &box_fragment.style(),
269                box_fragment.border_rect(),
270                box_fragment.border_radius(),
271                box_fragment.base.flags,
272                Cursor::Default,
273            ),
274            Fragment::Text(text) => hit_test_fragment_inner(
275                &text.base.style(),
276                text.base.rect(),
277                BorderRadius::zero(),
278                FragmentFlags::empty(),
279                Cursor::Text,
280            ),
281            _ => false,
282        }
283    }
284}
285
286fn rounded_rect_contains_point(
287    rect: LayoutRect,
288    border_radius: &BorderRadius,
289    point: LayoutPoint,
290) -> bool {
291    if !rect.contains(point) {
292        return false;
293    }
294
295    if border_radius.is_zero() {
296        return true;
297    }
298
299    let check_corner = |corner: LayoutPoint, radius: &LayoutSize, is_right, is_bottom| {
300        let mut origin = corner;
301        if is_right {
302            origin.x -= radius.width;
303        }
304        if is_bottom {
305            origin.y -= radius.height;
306        }
307        if !Box2D::from_origin_and_size(origin, *radius).contains(point) {
308            return true;
309        }
310        let center = (
311            if is_right {
312                corner.x - radius.width
313            } else {
314                corner.x + radius.width
315            },
316            if is_bottom {
317                corner.y - radius.height
318            } else {
319                corner.y + radius.height
320            },
321        );
322        let radius = (radius.width as f64, radius.height as f64);
323        Ellipse::new(center, radius, 0.0).contains((point.x, point.y).into())
324    };
325
326    check_corner(rect.top_left(), &border_radius.top_left, false, false) &&
327        check_corner(rect.top_right(), &border_radius.top_right, true, false) &&
328        check_corner(rect.bottom_right(), &border_radius.bottom_right, true, true) &&
329        check_corner(rect.bottom_left(), &border_radius.bottom_left, false, true)
330}
331
332fn cursor(kind: CursorKind, auto_cursor: Cursor) -> Cursor {
333    match kind {
334        CursorKind::Auto => auto_cursor,
335        CursorKind::None => Cursor::None,
336        CursorKind::Default => Cursor::Default,
337        CursorKind::Pointer => Cursor::Pointer,
338        CursorKind::ContextMenu => Cursor::ContextMenu,
339        CursorKind::Help => Cursor::Help,
340        CursorKind::Progress => Cursor::Progress,
341        CursorKind::Wait => Cursor::Wait,
342        CursorKind::Cell => Cursor::Cell,
343        CursorKind::Crosshair => Cursor::Crosshair,
344        CursorKind::Text => Cursor::Text,
345        CursorKind::VerticalText => Cursor::VerticalText,
346        CursorKind::Alias => Cursor::Alias,
347        CursorKind::Copy => Cursor::Copy,
348        CursorKind::Move => Cursor::Move,
349        CursorKind::NoDrop => Cursor::NoDrop,
350        CursorKind::NotAllowed => Cursor::NotAllowed,
351        CursorKind::Grab => Cursor::Grab,
352        CursorKind::Grabbing => Cursor::Grabbing,
353        CursorKind::EResize => Cursor::EResize,
354        CursorKind::NResize => Cursor::NResize,
355        CursorKind::NeResize => Cursor::NeResize,
356        CursorKind::NwResize => Cursor::NwResize,
357        CursorKind::SResize => Cursor::SResize,
358        CursorKind::SeResize => Cursor::SeResize,
359        CursorKind::SwResize => Cursor::SwResize,
360        CursorKind::WResize => Cursor::WResize,
361        CursorKind::EwResize => Cursor::EwResize,
362        CursorKind::NsResize => Cursor::NsResize,
363        CursorKind::NeswResize => Cursor::NeswResize,
364        CursorKind::NwseResize => Cursor::NwseResize,
365        CursorKind::ColResize => Cursor::ColResize,
366        CursorKind::RowResize => Cursor::RowResize,
367        CursorKind::AllScroll => Cursor::AllScroll,
368        CursorKind::ZoomIn => Cursor::ZoomIn,
369        CursorKind::ZoomOut => Cursor::ZoomOut,
370    }
371}