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 app_units::Au;
6use base::id::ScrollTreeNodeId;
7use embedder_traits::Cursor;
8use euclid::{Box2D, Vector2D};
9use kurbo::{Ellipse, Shape};
10use layout_api::{ElementsFromPointFlags, ElementsFromPointResult};
11use rustc_hash::FxHashMap;
12use servo_geometry::FastLayoutTransform;
13use style::computed_values::backface_visibility::T as BackfaceVisibility;
14use style::computed_values::pointer_events::T as PointerEvents;
15use style::computed_values::visibility::T as Visibility;
16use style::properties::ComputedValues;
17use style::values::computed::ui::CursorKind;
18use webrender_api::BorderRadius;
19use webrender_api::units::{LayoutPoint, LayoutRect, LayoutSize, RectExt};
20
21use crate::display_list::clip::{Clip, ClipId};
22use crate::display_list::stacking_context::StackingContextSection;
23use crate::display_list::{
24    StackingContext, StackingContextContent, StackingContextTree, ToWebRender,
25};
26use crate::fragment_tree::{Fragment, FragmentFlags};
27use crate::geom::PhysicalRect;
28
29pub(crate) struct HitTest<'a> {
30    /// The flags which describe how to perform this [`HitTest`].
31    flags: ElementsFromPointFlags,
32    /// The point to test for this hit test, relative to the page.
33    point_to_test: LayoutPoint,
34    /// A cached version of [`Self::point_to_test`] projected to a spatial node, to avoid
35    /// doing a lot of matrix math over and over.
36    projected_point_to_test: Option<(ScrollTreeNodeId, LayoutPoint, FastLayoutTransform)>,
37    /// The stacking context tree against which to perform the hit test.
38    stacking_context_tree: &'a StackingContextTree,
39    /// The resulting [`HitTestResultItems`] for this hit test.
40    results: Vec<ElementsFromPointResult>,
41    /// A cache of hit test results for shared clip nodes.
42    clip_hit_test_results: FxHashMap<ClipId, bool>,
43}
44
45impl<'a> HitTest<'a> {
46    pub(crate) fn run(
47        stacking_context_tree: &'a StackingContextTree,
48        point_to_test: LayoutPoint,
49        flags: ElementsFromPointFlags,
50    ) -> Vec<ElementsFromPointResult> {
51        let mut hit_test = Self {
52            flags,
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        };
59        stacking_context_tree
60            .root_stacking_context
61            .hit_test(&mut hit_test);
62        hit_test.results
63    }
64
65    /// Perform a hit test against a the clip node for the given [`ClipId`], returning
66    /// true if it is not clipped out or false if is clipped out.
67    fn hit_test_clip_id(&mut self, clip_id: ClipId) -> bool {
68        if clip_id == ClipId::INVALID {
69            return true;
70        }
71
72        if let Some(result) = self.clip_hit_test_results.get(&clip_id) {
73            return *result;
74        }
75
76        let clip = self.stacking_context_tree.clip_store.get(clip_id);
77        let result = self
78            .location_in_spatial_node(clip.parent_scroll_node_id)
79            .is_some_and(|(point, _)| {
80                clip.contains(point) && self.hit_test_clip_id(clip.parent_clip_id)
81            });
82        self.clip_hit_test_results.insert(clip_id, result);
83        result
84    }
85
86    /// Get the hit test location in the coordinate system of the given spatial node,
87    /// returning `None` if the transformation is uninvertible or the point cannot be
88    /// projected into the spatial node.
89    fn location_in_spatial_node(
90        &mut self,
91        scroll_tree_node_id: ScrollTreeNodeId,
92    ) -> Option<(LayoutPoint, FastLayoutTransform)> {
93        match self.projected_point_to_test {
94            Some((cached_scroll_tree_node_id, projected_point, transform))
95                if cached_scroll_tree_node_id == scroll_tree_node_id =>
96            {
97                return Some((projected_point, transform));
98            },
99            _ => {},
100        }
101
102        let transform = self
103            .stacking_context_tree
104            .compositor_info
105            .scroll_tree
106            .cumulative_root_to_node_transform(scroll_tree_node_id)?;
107
108        let projected_point = transform.project_point2d(self.point_to_test)?;
109
110        self.projected_point_to_test = Some((scroll_tree_node_id, projected_point, transform));
111        Some((projected_point, transform))
112    }
113}
114
115impl Clip {
116    fn contains(&self, point: LayoutPoint) -> bool {
117        rounded_rect_contains_point(self.rect, &self.radii, point)
118    }
119}
120
121impl StackingContext {
122    /// Perform a hit test against a [`StackingContext`]. Note that this is the reverse
123    /// of the stacking context walk algorithm in `stacking_context.rs`. Any changes made
124    /// here should be reflected in the forward version in that file.
125    fn hit_test(&self, hit_test: &mut HitTest) -> bool {
126        let mut contents = self.contents.iter().rev().peekable();
127
128        // Step 10: Outlines
129        while contents
130            .peek()
131            .is_some_and(|child| child.section() == StackingContextSection::Outline)
132        {
133            // The hit test will not hit the outline.
134            let _ = contents.next().unwrap();
135        }
136
137        // Steps 8 and 9: Stacking contexts with non-negative ‘z-index’, and
138        // positioned stacking containers (where ‘z-index’ is auto)
139        let mut real_stacking_contexts_and_positioned_stacking_containers = self
140            .real_stacking_contexts_and_positioned_stacking_containers
141            .iter()
142            .rev()
143            .peekable();
144        while real_stacking_contexts_and_positioned_stacking_containers
145            .peek()
146            .is_some_and(|child| child.z_index() >= 0)
147        {
148            let child = real_stacking_contexts_and_positioned_stacking_containers
149                .next()
150                .unwrap();
151            if child.hit_test(hit_test) {
152                return true;
153            }
154        }
155
156        // Steps 7 and 8: Fragments and inline stacking containers
157        while contents
158            .peek()
159            .is_some_and(|child| child.section() == StackingContextSection::Foreground)
160        {
161            let child = contents.next().unwrap();
162            if self.hit_test_content(child, hit_test) {
163                return true;
164            }
165        }
166
167        // Step 6: Float stacking containers
168        for child in self.float_stacking_containers.iter().rev() {
169            if child.hit_test(hit_test) {
170                return true;
171            }
172        }
173
174        // Step 5: Block backgrounds and borders
175        while contents.peek().is_some_and(|child| {
176            child.section() == StackingContextSection::DescendantBackgroundsAndBorders
177        }) {
178            let child = contents.next().unwrap();
179            if self.hit_test_content(child, hit_test) {
180                return true;
181            }
182        }
183
184        // Step 4: Stacking contexts with negative ‘z-index’
185        for child in real_stacking_contexts_and_positioned_stacking_containers {
186            if child.hit_test(hit_test) {
187                return true;
188            }
189        }
190
191        // Steps 2 and 3: Borders and background for the root
192        while contents.peek().is_some_and(|child| {
193            child.section() == StackingContextSection::OwnBackgroundsAndBorders
194        }) {
195            let child = contents.next().unwrap();
196            if self.hit_test_content(child, hit_test) {
197                return true;
198            }
199        }
200        false
201    }
202
203    pub(crate) fn hit_test_content(
204        &self,
205        content: &StackingContextContent,
206        hit_test: &mut HitTest<'_>,
207    ) -> bool {
208        match content {
209            StackingContextContent::Fragment {
210                scroll_node_id,
211                clip_id,
212                containing_block,
213                fragment,
214                ..
215            } => {
216                hit_test.hit_test_clip_id(*clip_id) &&
217                    fragment.hit_test(hit_test, *scroll_node_id, containing_block)
218            },
219            StackingContextContent::AtomicInlineStackingContainer { index } => {
220                self.atomic_inline_stacking_containers[*index].hit_test(hit_test)
221            },
222        }
223    }
224}
225
226impl Fragment {
227    pub(crate) fn hit_test(
228        &self,
229        hit_test: &mut HitTest,
230        spatial_node_id: ScrollTreeNodeId,
231        containing_block: &PhysicalRect<Au>,
232    ) -> bool {
233        let Some(tag) = self.tag() else {
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                    if style.get_inherited_ui().pointer_events == PointerEvents::None {
247                        return false;
248                    }
249                    if style.get_inherited_box().visibility != Visibility::Visible {
250                        return false;
251                    }
252                }
253
254                let (point_in_spatial_node, transform) =
255                    match hit_test.location_in_spatial_node(spatial_node_id) {
256                        Some(point) => point,
257                        None => return false,
258                    };
259
260                if !is_root_element &&
261                    style.get_box().backface_visibility == BackfaceVisibility::Hidden &&
262                    transform.is_backface_visible()
263                {
264                    return false;
265                }
266
267                let fragment_rect = fragment_rect.translate(containing_block.origin.to_vector());
268                if is_root_element {
269                    let viewport_size = hit_test
270                        .stacking_context_tree
271                        .compositor_info
272                        .viewport_details
273                        .size;
274                    let viewport_rect = LayoutRect::from_origin_and_size(
275                        Default::default(),
276                        viewport_size.cast_unit(),
277                    );
278                    if !viewport_rect.contains(hit_test.point_to_test) {
279                        return false;
280                    }
281                } else if !rounded_rect_contains_point(
282                    fragment_rect.to_webrender(),
283                    &border_radius,
284                    point_in_spatial_node,
285                ) {
286                    return false;
287                }
288
289                let point_in_target = point_in_spatial_node.cast_unit() -
290                    Vector2D::new(
291                        fragment_rect.origin.x.to_f32_px(),
292                        fragment_rect.origin.y.to_f32_px(),
293                    );
294
295                hit_test.results.push(ElementsFromPointResult {
296                    node: tag.node,
297                    point_in_target,
298                    cursor: cursor(style.get_inherited_ui().cursor.keyword, auto_cursor),
299                });
300                !hit_test.flags.contains(ElementsFromPointFlags::FindAll)
301            };
302
303        match self {
304            Fragment::Box(box_fragment) | Fragment::Float(box_fragment) => {
305                let box_fragment = box_fragment.borrow();
306                hit_test_fragment_inner(
307                    &box_fragment.style,
308                    box_fragment.border_rect(),
309                    box_fragment.border_radius(),
310                    box_fragment.base.flags,
311                    Cursor::Default,
312                )
313            },
314            Fragment::Text(text) => {
315                let text = &*text.borrow();
316                hit_test_fragment_inner(
317                    &text.inline_styles.style.borrow(),
318                    text.rect,
319                    BorderRadius::zero(),
320                    FragmentFlags::empty(),
321                    Cursor::Text,
322                )
323            },
324            _ => false,
325        }
326    }
327}
328
329fn rounded_rect_contains_point(
330    rect: LayoutRect,
331    border_radius: &BorderRadius,
332    point: LayoutPoint,
333) -> bool {
334    if !rect.contains(point) {
335        return false;
336    }
337
338    if border_radius.is_zero() {
339        return true;
340    }
341
342    let check_corner = |corner: LayoutPoint, radius: &LayoutSize, is_right, is_bottom| {
343        let mut origin = corner;
344        if is_right {
345            origin.x -= radius.width;
346        }
347        if is_bottom {
348            origin.y -= radius.height;
349        }
350        if !Box2D::from_origin_and_size(origin, *radius).contains(point) {
351            return true;
352        }
353        let center = (
354            if is_right {
355                corner.x - radius.width
356            } else {
357                corner.x + radius.width
358            },
359            if is_bottom {
360                corner.y - radius.height
361            } else {
362                corner.y + radius.height
363            },
364        );
365        let radius = (radius.width as f64, radius.height as f64);
366        Ellipse::new(center, radius, 0.0).contains((point.x, point.y).into())
367    };
368
369    check_corner(rect.top_left(), &border_radius.top_left, false, false) &&
370        check_corner(rect.top_right(), &border_radius.top_right, true, false) &&
371        check_corner(rect.bottom_right(), &border_radius.bottom_right, true, true) &&
372        check_corner(rect.bottom_left(), &border_radius.bottom_left, false, true)
373}
374
375fn cursor(kind: CursorKind, auto_cursor: Cursor) -> Cursor {
376    match kind {
377        CursorKind::Auto => auto_cursor,
378        CursorKind::None => Cursor::None,
379        CursorKind::Default => Cursor::Default,
380        CursorKind::Pointer => Cursor::Pointer,
381        CursorKind::ContextMenu => Cursor::ContextMenu,
382        CursorKind::Help => Cursor::Help,
383        CursorKind::Progress => Cursor::Progress,
384        CursorKind::Wait => Cursor::Wait,
385        CursorKind::Cell => Cursor::Cell,
386        CursorKind::Crosshair => Cursor::Crosshair,
387        CursorKind::Text => Cursor::Text,
388        CursorKind::VerticalText => Cursor::VerticalText,
389        CursorKind::Alias => Cursor::Alias,
390        CursorKind::Copy => Cursor::Copy,
391        CursorKind::Move => Cursor::Move,
392        CursorKind::NoDrop => Cursor::NoDrop,
393        CursorKind::NotAllowed => Cursor::NotAllowed,
394        CursorKind::Grab => Cursor::Grab,
395        CursorKind::Grabbing => Cursor::Grabbing,
396        CursorKind::EResize => Cursor::EResize,
397        CursorKind::NResize => Cursor::NResize,
398        CursorKind::NeResize => Cursor::NeResize,
399        CursorKind::NwResize => Cursor::NwResize,
400        CursorKind::SResize => Cursor::SResize,
401        CursorKind::SeResize => Cursor::SeResize,
402        CursorKind::SwResize => Cursor::SwResize,
403        CursorKind::WResize => Cursor::WResize,
404        CursorKind::EwResize => Cursor::EwResize,
405        CursorKind::NsResize => Cursor::NsResize,
406        CursorKind::NeswResize => Cursor::NeswResize,
407        CursorKind::NwseResize => Cursor::NwseResize,
408        CursorKind::ColResize => Cursor::ColResize,
409        CursorKind::RowResize => Cursor::RowResize,
410        CursorKind::AllScroll => Cursor::AllScroll,
411        CursorKind::ZoomIn => Cursor::ZoomIn,
412        CursorKind::ZoomOut => Cursor::ZoomOut,
413    }
414}