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