azul_core/hit_test.rs
1//! Hit-test result types for determining which DOM nodes are under the cursor,
2//! scroll state tracking, and pipeline/document identification. These types
3//! feed into the event dispatch system.
4
5use alloc::collections::BTreeMap;
6use core::{
7 fmt,
8 sync::atomic::{AtomicU32, Ordering as AtomicOrdering},
9};
10
11use crate::{
12 dom::{
13 DomId, DomNodeHash, DomNodeId, OptionDomNodeId, ScrollTagId, ScrollbarOrientation, TagId,
14 },
15 geom::{LogicalPosition, LogicalRect, LogicalSize},
16 id::NodeId,
17 resources::IdNamespace,
18 window::MouseCursorType,
19 OrderedMap,
20};
21
22/// Result of a hit test against a single DOM, containing all nodes hit
23/// by the cursor along with scroll, scrollbar, and cursor-type information.
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
25pub struct HitTest {
26 pub regular_hit_test_nodes: BTreeMap<NodeId, HitTestItem>,
27 pub scroll_hit_test_nodes: BTreeMap<NodeId, ScrollHitTestItem>,
28 /// Hit test results for scrollbar components.
29 pub scrollbar_hit_test_nodes: BTreeMap<ScrollbarHitId, ScrollbarHitTestItem>,
30 /// Hit test results for cursor areas (text runs with cursor property).
31 /// Maps `NodeId` to (`CursorType`, `hit_depth`) - the cursor type and z-depth of the hit.
32 pub cursor_hit_test_nodes: BTreeMap<NodeId, CursorHitTestItem>,
33}
34
35/// Hit test item for cursor areas (determines which cursor icon to show).
36#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
37#[repr(C)]
38pub struct CursorHitTestItem {
39 pub cursor_type: CursorType,
40 pub hit_depth: u32,
41 pub point_in_viewport: LogicalPosition,
42}
43
44impl HitTest {
45 #[must_use]
46 pub const fn empty() -> Self {
47 Self {
48 regular_hit_test_nodes: BTreeMap::new(),
49 scroll_hit_test_nodes: BTreeMap::new(),
50 scrollbar_hit_test_nodes: BTreeMap::new(),
51 cursor_hit_test_nodes: BTreeMap::new(),
52 }
53 }
54 #[must_use]
55 pub fn is_empty(&self) -> bool {
56 self.regular_hit_test_nodes.is_empty()
57 && self.scroll_hit_test_nodes.is_empty()
58 && self.scrollbar_hit_test_nodes.is_empty()
59 && self.cursor_hit_test_nodes.is_empty()
60 }
61}
62
63/// Unique identifier for a specific component of a scrollbar.
64#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
65#[repr(C, u8)]
66pub enum ScrollbarHitId {
67 VerticalTrack(DomId, NodeId),
68 VerticalThumb(DomId, NodeId),
69 HorizontalTrack(DomId, NodeId),
70 HorizontalThumb(DomId, NodeId),
71}
72
73/// Hit test item specifically for scrollbar components.
74#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
75#[repr(C)]
76pub struct ScrollbarHitTestItem {
77 pub point_in_viewport: LogicalPosition,
78 pub point_relative_to_item: LogicalPosition,
79 pub orientation: ScrollbarOrientation,
80}
81
82/// Scroll frame identifier combining a unique `u64` tag with its owning `PipelineId`.
83#[derive(Copy, Clone, Eq, Hash, PartialEq, Ord, PartialOrd)]
84#[repr(C)]
85pub struct ExternalScrollId(pub u64, pub PipelineId);
86
87impl ::core::fmt::Display for ExternalScrollId {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 write!(f, "ExternalScrollId({})", self.0)
90 }
91}
92
93impl ::core::fmt::Debug for ExternalScrollId {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 write!(f, "{self}")
96 }
97}
98
99/// A node whose content overflows its parent, requiring scroll handling.
100#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
101pub struct OverflowingScrollNode {
102 pub parent_rect: LogicalRect,
103 pub child_rect: LogicalRect,
104 pub virtual_child_rect: LogicalRect,
105 pub parent_external_scroll_id: ExternalScrollId,
106 pub parent_dom_hash: DomNodeHash,
107 pub scroll_tag_id: ScrollTagId,
108}
109
110impl Default for OverflowingScrollNode {
111 fn default() -> Self {
112 use crate::dom::TagId;
113 Self {
114 parent_rect: LogicalRect::zero(),
115 child_rect: LogicalRect::zero(),
116 virtual_child_rect: LogicalRect::zero(),
117 parent_external_scroll_id: ExternalScrollId(0, PipelineId::DUMMY),
118 parent_dom_hash: DomNodeHash { inner: 0 },
119 scroll_tag_id: ScrollTagId {
120 inner: TagId { inner: 0 },
121 },
122 }
123 }
124}
125
126/// Extra source identifier within a pipeline, allowing multiple independent
127/// subsystems to generate `PipelineId` values without collision.
128///
129/// All pipelines still share the same `IdNamespace` and `DocumentId`.
130pub type PipelineSourceId = u32;
131
132/// Information about a scroll frame, given to the user by the framework.
133///
134/// The two rects are NOT in the same coordinate space — never subtract one
135/// origin from the other. That silent ambiguity put the scrollbar thumb
136/// partway down the track for every container not at the window origin.
137/// See `ScrollManager::get_scroll_states_for_dom` for the producer.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd)]
139pub struct ScrollPosition {
140 /// The scroll container's border box in ABSOLUTE window coordinates.
141 /// `size` is the scrollport ("how big is the parent container", so
142 /// "scroll to left edge" can be implemented); `origin` is where that
143 /// container sits on screen and is only meaningful to scroll-into-view.
144 pub parent_rect: LogicalRect,
145 /// `size` = the scrollable content ("the union of all children", or the
146 /// `VirtualView` virtual size when one was reported).
147 /// `origin` = the SCROLL OFFSET ITSELF — distance already scrolled from
148 /// the scroll origin, normally clamped to `[0, content − container]`.
149 /// It is NOT an absolute position and NOT relative to
150 /// `parent_rect.origin`; content paints at `position − origin`.
151 pub children_rect: LogicalRect,
152}
153
154/// Identifies a document within a namespace, used for multi-document rendering.
155#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
156pub struct DocumentId {
157 pub namespace_id: IdNamespace,
158 pub id: u32,
159}
160
161impl ::core::fmt::Display for DocumentId {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 write!(
164 f,
165 "DocumentId {{ ns: {}, id: {} }}",
166 self.namespace_id, self.id
167 )
168 }
169}
170
171impl ::core::fmt::Debug for DocumentId {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 write!(f, "{self}")
174 }
175}
176
177/// Identifies a rendering pipeline by source and sequence number.
178#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
179pub struct PipelineId(pub PipelineSourceId, pub u32);
180
181impl ::core::fmt::Display for PipelineId {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 write!(f, "PipelineId({}, {})", self.0, self.1)
184 }
185}
186
187impl ::core::fmt::Debug for PipelineId {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 write!(f, "{self}")
190 }
191}
192
193static LAST_PIPELINE_ID: AtomicU32 = AtomicU32::new(0);
194
195impl Default for PipelineId {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201impl PipelineId {
202 pub const DUMMY: Self = Self(0, 0);
203
204 pub fn new() -> Self {
205 Self(LAST_PIPELINE_ID.fetch_add(1, AtomicOrdering::SeqCst), 0)
206 }
207}
208
209/// A single hit-test result for a regular (non-scroll) DOM node.
210#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
211pub struct HitTestItem {
212 /// The hit point in the coordinate space of the "viewport" of the display item.
213 /// The viewport is the scroll node formed by the root reference frame of the display item's
214 /// pipeline.
215 pub point_in_viewport: LogicalPosition,
216 /// The hit point relative to this node's static CONTENT-box origin, with
217 /// the node's own scroll offset NOT applied.
218 ///
219 /// ## Why this space, and why it is typed
220 ///
221 /// The two hosts used to disagree here. `WebRender` reports the point
222 /// relative to the hit RECT, which azul pushes before the scroll frame —
223 /// i.e. [`BorderBoxLocal`](crate::spaces::BorderBoxLocal) — while the CPU
224 /// tester used in headless E2E subtracted `padding + border` and reported
225 /// [`ContentBoxLocal`]. Same click, two answers, differing by exactly the
226 /// node's content inset. It stayed latent only because the default
227 /// `TextInput`'s value `<p>` sets neither padding nor border; it goes live
228 /// for any padded editable.
229 ///
230 /// Both producers now emit `ContentBoxLocal`, because that is the space
231 /// the consumer that cares (`UnifiedLayout::hittest_cursor`, via
232 /// `LayoutWindow::ifc_local_point`) actually needs. Consumers wanting the
233 /// border-box-relative point the public
234 /// `CallbackInfo::get_cursor_relative_to_node` promises convert back with
235 /// [`ContentBoxLocal::to_border_box_local`].
236 ///
237 /// [`ContentBoxLocal`]: crate::spaces::ContentBoxLocal
238 /// [`ContentBoxLocal::to_border_box_local`]: crate::spaces::ContentBoxLocal::to_border_box_local
239 pub point_relative_to_item: crate::spaces::ContentBoxLocal,
240 /// Necessary to easily get the nearest `VirtualView` node
241 pub is_focusable: bool,
242 /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
243 pub is_virtual_view_hit: Option<(DomId, LogicalPosition)>,
244 /// Z-order depth from `WebRender` hit test (0 = frontmost/topmost in z-order).
245 /// Lower values are closer to the user. This preserves the ordering from
246 /// `WebRender`'s hit test results which returns items front-to-back.
247 pub hit_depth: u32,
248}
249
250/// A hit-test result for a scrollable DOM node.
251#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
252pub struct ScrollHitTestItem {
253 /// The hit point in the coordinate space of the "viewport" of the display item.
254 /// The viewport is the scroll node formed by the root reference frame of the display item's
255 /// pipeline.
256 pub point_in_viewport: LogicalPosition,
257 /// The hit point relative to the container's static BORDER-box origin,
258 /// with its own scroll NOT applied — i.e. where in its scrollPORT the
259 /// pointer is.
260 ///
261 /// Deliberately a different space from [`HitTestItem::point_relative_to_item`]:
262 /// this one is about the scroll box's chrome (which edge is the pointer
263 /// near, is it over the scrollbar gutter), not about its text content, and
264 /// scroll geometry is measured against the border box everywhere else.
265 pub point_relative_to_item: crate::spaces::BorderBoxLocal,
266 /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
267 pub scroll_node: OverflowingScrollNode,
268}
269
270/// Map of active scroll states, keyed by their external scroll ID.
271#[derive(Debug, Default)]
272pub struct ScrollStates(pub OrderedMap<ExternalScrollId, ScrollState>);
273
274impl ScrollStates {
275 #[must_use]
276 pub fn new() -> Self {
277 Self::default()
278 }
279
280 #[must_use]
281 pub fn get_scroll_position(&self, scroll_id: &ExternalScrollId) -> Option<LogicalPosition> {
282 self.0.get(scroll_id).map(ScrollState::get)
283 }
284
285 /// Set the scroll amount - does not update the `entry.used_this_frame`,
286 /// since that is only relevant when we are actually querying the renderer.
287 pub fn set_scroll_position(
288 &mut self,
289 node: &OverflowingScrollNode,
290 scroll_position: LogicalPosition,
291 ) {
292 let max_scroll = max_scroll_rect(node);
293 self.0
294 .entry(node.parent_external_scroll_id)
295 .or_default()
296 .set(scroll_position.x, scroll_position.y, &max_scroll);
297 }
298
299 /// Updating (add to) the existing scroll amount does not update the
300 /// `entry.used_this_frame`, since that is only relevant when we are
301 /// actually querying the renderer.
302 pub fn scroll_node(
303 &mut self,
304 node: &OverflowingScrollNode,
305 scroll_by_x: f32,
306 scroll_by_y: f32,
307 ) {
308 let max_scroll = max_scroll_rect(node);
309 self.0
310 .entry(node.parent_external_scroll_id)
311 .or_default()
312 .add(scroll_by_x, scroll_by_y, &max_scroll);
313 }
314}
315
316/// Compute the maximum scrollable range for a scroll node.
317///
318/// The maximum scroll offset is `content − viewport` (`child_rect − parent_rect`),
319/// clamped to `>= 0`. Previously the scroll position was clamped to the full
320/// content size, which let the content scroll entirely out of view. The returned
321/// rect keeps `child_rect.origin` and stores the max offset in `size`.
322fn max_scroll_rect(node: &OverflowingScrollNode) -> LogicalRect {
323 LogicalRect::new(
324 node.child_rect.origin,
325 LogicalSize::new(
326 (node.child_rect.size.width - node.parent_rect.size.width).max(0.0),
327 (node.child_rect.size.height - node.parent_rect.size.height).max(0.0),
328 ),
329 )
330}
331
332/// Current scroll position for a single scroll frame.
333#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
334#[repr(C)]
335pub struct ScrollState {
336 /// Amount in pixel that the current node is scrolled
337 pub scroll_position: LogicalPosition,
338}
339
340impl_option!(
341 ScrollState,
342 OptionScrollState,
343 [Debug, Copy, Clone, PartialEq, Eq, PartialOrd]
344);
345
346impl ScrollState {
347 /// Return the current position of the scroll state
348 #[must_use]
349 pub const fn get(&self) -> LogicalPosition {
350 self.scroll_position
351 }
352
353 /// Add a scroll X / Y onto the existing scroll state.
354 ///
355 /// `max_scroll_rect` is the *scroll range* rect: its size is the maximum
356 /// scrollable offset (`content − viewport`, clamped to `>= 0`), NOT the full
357 /// content size. See [`ScrollStates::scroll_node`]. Clamping via `.max(0.0)`
358 /// first also collapses any NaN input to `0.0` (`f32::max` returns the
359 /// non-NaN operand), so a NaN delta can never poison the scroll position.
360 pub fn add(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
361 self.scroll_position.x = (self.scroll_position.x + x)
362 .max(0.0)
363 .min(max_scroll_rect.size.width.max(0.0));
364 self.scroll_position.y = (self.scroll_position.y + y)
365 .max(0.0)
366 .min(max_scroll_rect.size.height.max(0.0));
367 }
368
369 /// Set the scroll state to a new position.
370 ///
371 /// `max_scroll_rect` is the *scroll range* rect (see [`ScrollState::add`]).
372 pub const fn set(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
373 self.scroll_position.x = x.max(0.0).min(max_scroll_rect.size.width.max(0.0));
374 self.scroll_position.y = y.max(0.0).min(max_scroll_rect.size.height.max(0.0));
375 }
376}
377
378impl Default for ScrollState {
379 fn default() -> Self {
380 Self {
381 scroll_position: LogicalPosition::zero(),
382 }
383 }
384}
385
386/// Complete hit-test result across all DOMs, including the currently focused node.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct FullHitTest {
389 pub hovered_nodes: BTreeMap<DomId, HitTest>,
390 pub focused_node: OptionDomNodeId,
391}
392
393impl FullHitTest {
394 /// Every node under the pointer, FRONT TO BACK (9g-ii-e).
395 ///
396 /// # Why this exists rather than the struct being exposed
397 ///
398 /// `FullHitTest` is a `BTreeMap` of `DomId` to a `HitTest` that is itself
399 /// four more maps. Exposing that shape across the C ABI needs five map
400 /// types nothing else would use, and no application wants the shape - it
401 /// wants the ANSWER: what is under the cursor, nearest first. So the
402 /// accessors return that, the same trade `get_hid_reports` makes by
403 /// returning an owned vec rather than a borrowed slice.
404 ///
405 /// Ordered by `hit_depth`, which `WebRender` and the CPU tester both fill
406 /// with a real z-order (0 = frontmost). That is a stronger ordering than
407 /// the `NodeId` one some call sites use as a proxy: two overlapping
408 /// absolutely-positioned nodes can have any id relationship, and the one
409 /// on top is the one with the lower depth.
410 ///
411 /// Only the REGULAR hits: scroll frames, scrollbar parts and cursor areas
412 /// are hit-tested separately and are not nodes an app would call "under
413 /// the pointer" - a scrollbar is not the content beneath it.
414 #[must_use]
415 pub fn hovered_node_ids(&self) -> Vec<DomNodeId> {
416 let mut with_depth: Vec<(u32, DomNodeId)> = Vec::new();
417 for (dom_id, hit) in &self.hovered_nodes {
418 for (node_id, item) in &hit.regular_hit_test_nodes {
419 with_depth.push((
420 item.hit_depth,
421 DomNodeId {
422 dom: *dom_id,
423 node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
424 *node_id,
425 )),
426 },
427 ));
428 }
429 }
430 // Stable within a depth, so two nodes a backend reported at the same
431 // z-order keep the order the maps gave them rather than an arbitrary
432 // one that changes between frames.
433 with_depth.sort_by_key(|(depth, _)| *depth);
434 with_depth.into_iter().map(|(_, id)| id).collect()
435 }
436
437 /// The node nearest the user, or `None` when the pointer is over nothing.
438 ///
439 /// What an app almost always means by "the hit test": the topmost regular
440 /// hit. Cheaper than [`Self::hovered_node_ids`] because it does not sort.
441 #[must_use]
442 pub fn topmost_node(&self) -> Option<DomNodeId> {
443 let mut best: Option<(u32, DomNodeId)> = None;
444 for (dom_id, hit) in &self.hovered_nodes {
445 for (node_id, item) in &hit.regular_hit_test_nodes {
446 let candidate = DomNodeId {
447 dom: *dom_id,
448 node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
449 *node_id,
450 )),
451 };
452 // STRICTLY less, so the first node at the frontmost depth wins
453 // and the answer does not flip between frames for a tie.
454 if best.is_none_or(|(d, _)| item.hit_depth < d) {
455 best = Some((item.hit_depth, candidate));
456 }
457 }
458 }
459 best.map(|(_, id)| id)
460 }
461}
462
463impl FullHitTest {
464 /// Create an empty hit-test result
465 #[must_use]
466 pub fn empty(focused_node: Option<DomNodeId>) -> Self {
467 Self {
468 hovered_nodes: BTreeMap::new(),
469 focused_node: focused_node.into(),
470 }
471 }
472
473 /// Returns `true` if no nodes were hovered (ignores `focused_node`).
474 #[must_use]
475 pub fn is_empty(&self) -> bool {
476 self.hovered_nodes.is_empty()
477 }
478}
479
480/// Result of determining which mouse cursor icon to display based on hit-test results.
481#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
482pub struct CursorTypeHitTest {
483 /// closest-node is used for determining the cursor: property
484 /// The node is guaranteed to have a non-default cursor: property,
485 /// so that the cursor icon can be set accordingly
486 pub cursor_node: Option<(DomId, NodeId)>,
487 /// Mouse cursor type to set (if `cursor_node` is None, this is set to
488 /// `MouseCursorType::Default`)
489 pub cursor_icon: MouseCursorType,
490}
491
492// ============================================================================
493// Type-safe hit-test tag system (merged from the former `hit_test_tag` module).
494//
495// Encodes WebRender's ItemTag = (u64, u16): the tag *type* lives in the upper
496// byte of tag.1 (DOM node / scrollbar / selection / cursor / scroll-container),
497// keeping tag types free of bit-level conflicts. See the TAG_TYPE_* constants.
498// ============================================================================
499// ============================================================================
500// Tag Type Markers (stored in upper byte of ItemTag.1)
501// ============================================================================
502
503/// Marker for DOM node tags (regular UI elements with callbacks, focus, etc.)
504pub const TAG_TYPE_DOM_NODE: u16 = 0x0100;
505
506/// Marker for scrollbar component tags
507pub const TAG_TYPE_SCROLLBAR: u16 = 0x0200;
508
509/// Marker for text selection hit-test areas (determines text selection regions)
510///
511/// These are pushed for text runs to enable text selection without affecting
512/// other hit-test logic. Selection may trigger re-rendering.
513///
514/// NOTE: Text selection hit-testing currently uses `TAG_TYPE_CURSOR` (0x0400).
515/// This constant is used by the `HitTestTag::Selection` variant for encoding
516/// selection-specific tags (e.g., text run selection areas).
517pub const TAG_TYPE_SELECTION: u16 = 0x0300;
518
519/// Marker for cursor hit-test areas (determines which cursor icon to show)
520///
521/// These are separate from DOM node tags to allow efficient cursor resolution
522/// without iterating over all DOM nodes. Cursor changes never require re-rendering.
523pub const TAG_TYPE_CURSOR: u16 = 0x0400;
524
525/// Marker for scroll container hit-test areas (for trackpad/wheel scrolling)
526///
527/// These identify scrollable containers even when no DOM node callbacks are registered.
528/// Scroll containers push this tag so the scroll manager can find them during wheel events.
529pub const TAG_TYPE_SCROLL_CONTAINER: u16 = 0x0500;
530
531// ============================================================================
532// Scrollbar Component Types (stored in lower byte of ItemTag.1 for scrollbar tags)
533// ============================================================================
534
535/// Scrollbar component type identifier.
536///
537/// Each scrollable container can have up to 2 scrollbars (vertical + horizontal),
538/// and each scrollbar has 2 main hit regions (track + thumb).
539///
540/// Future extensions could add:
541/// - `UpButton`, `DownButton`, `LeftButton`, `RightButton` for scroll arrows
542/// - `PageUp`, `PageDown` for page-scroll regions
543#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
544#[repr(u8)]
545pub enum ScrollbarComponent {
546 /// The vertical scrollbar track (background area)
547 VerticalTrack = 0,
548 /// The vertical scrollbar thumb (draggable handle)
549 VerticalThumb = 1,
550 /// The horizontal scrollbar track (background area)
551 HorizontalTrack = 2,
552 /// The horizontal scrollbar thumb (draggable handle)
553 HorizontalThumb = 3,
554 // Future: scroll arrow buttons
555 // VerticalUpButton = 4,
556 // VerticalDownButton = 5,
557 // HorizontalLeftButton = 6,
558 // HorizontalRightButton = 7,
559}
560
561impl ScrollbarComponent {
562 /// Convert from raw u8 value
563 #[must_use]
564 pub const fn from_u8(value: u8) -> Option<Self> {
565 match value {
566 0 => Some(Self::VerticalTrack),
567 1 => Some(Self::VerticalThumb),
568 2 => Some(Self::HorizontalTrack),
569 3 => Some(Self::HorizontalThumb),
570 _ => None,
571 }
572 }
573}
574
575// ============================================================================
576// WebRender Hit-Test Tag (unified type-safe representation)
577// ============================================================================
578
579/// Unified, type-safe representation of a `WebRender` hit-test tag.
580///
581/// This enum represents all possible types of hit-test targets. Each variant
582/// can be encoded to and decoded from `WebRender`'s `(u64, u16)` `ItemTag` format.
583///
584/// ## Namespace Separation
585///
586/// Different tag types are kept in separate namespaces to:
587/// - Enable efficient hit-test queries (only iterate over relevant tags)
588/// - Get automatic depth sorting from `WebRender` per namespace
589/// - Prevent accidental collisions between different hit-test purposes
590///
591/// | Namespace | Purpose |
592/// |-----------|--------------------------------------|
593/// | 0x0100 | DOM nodes (callbacks, focus, hover) |
594/// | 0x0200 | Scrollbar components |
595/// | 0x0300 | Selection areas (text selection) |
596/// | 0x0400 | Cursor areas (cursor icon display) |
597#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
598pub enum HitTestTag {
599 /// A regular DOM node (button, div, text container, etc.)
600 ///
601 /// These are nodes that have callbacks, are focusable, or have hover styles.
602 /// The `TagId` is a sequential counter assigned during DOM styling.
603 DomNode {
604 /// The unique tag ID assigned to this DOM node
605 tag_id: TagId,
606 },
607
608 /// A scrollbar component (track or thumb)
609 ///
610 /// Each scrollable container can have up to 2 scrollbars.
611 /// The scrollbar is identified by the `DomId` and `NodeId` of the scrollable container.
612 Scrollbar {
613 /// The DOM that contains the scrollable container
614 dom_id: DomId,
615 /// The `NodeId` of the scrollable container (not the scrollbar itself)
616 node_id: NodeId,
617 /// Which component of the scrollbar was hit
618 component: ScrollbarComponent,
619 },
620
621 /// A cursor hit-test area (determines which cursor icon to display)
622 ///
623 /// These are pushed separately from DOM nodes to allow efficient cursor
624 /// resolution. The cursor type is encoded in the lower byte of tag.1.
625 Cursor {
626 /// The DOM node this cursor area belongs to
627 dom_id: DomId,
628 /// The `NodeId` of the element with the cursor property
629 node_id: NodeId,
630 /// The cursor type to display when hovering over this area
631 cursor_type: CursorType,
632 },
633
634 /// A text selection hit-test area
635 ///
636 /// These are pushed for text runs to enable text selection.
637 /// Separate from DOM nodes to prevent interference with other hit-testing.
638 Selection {
639 /// The DOM containing the text
640 dom_id: DomId,
641 /// The `NodeId` of the text container (not the Text node itself)
642 container_node_id: NodeId,
643 /// The index of the text run within the container (for multi-line text)
644 text_run_index: u16,
645 },
646}
647
648/// Cursor type encoded in cursor hit-test tags.
649/// Stored in the lower byte of the ItemTag.1 field.
650#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
651#[repr(u8)]
652pub enum CursorType {
653 #[default]
654 Default = 0,
655 Pointer = 1,
656 Text = 2,
657 Crosshair = 3,
658 Move = 4,
659 NotAllowed = 5,
660 Grab = 6,
661 Grabbing = 7,
662 EResize = 8,
663 WResize = 9,
664 NResize = 10,
665 SResize = 11,
666 EwResize = 12,
667 NsResize = 13,
668 NeswResize = 14,
669 NwseResize = 15,
670 ColResize = 16,
671 RowResize = 17,
672 Wait = 18,
673 Help = 19,
674 Progress = 20,
675 // Add more as needed, up to 255
676}
677
678impl CursorType {
679 /// Convert from raw u8 value
680 // Explicit u8 -> variant table documenting every discriminant; `0 => Default`
681 // intentionally mirrors the `_ => Default` fallback (the `#[default]` is 0).
682 #[allow(clippy::match_same_arms)]
683 #[must_use]
684 pub const fn from_u8(value: u8) -> Self {
685 match value {
686 0 => Self::Default,
687 1 => Self::Pointer,
688 2 => Self::Text,
689 3 => Self::Crosshair,
690 4 => Self::Move,
691 5 => Self::NotAllowed,
692 6 => Self::Grab,
693 7 => Self::Grabbing,
694 8 => Self::EResize,
695 9 => Self::WResize,
696 10 => Self::NResize,
697 11 => Self::SResize,
698 12 => Self::EwResize,
699 13 => Self::NsResize,
700 14 => Self::NeswResize,
701 15 => Self::NwseResize,
702 16 => Self::ColResize,
703 17 => Self::RowResize,
704 18 => Self::Wait,
705 19 => Self::Help,
706 20 => Self::Progress,
707 _ => Self::Default,
708 }
709 }
710}
711
712impl HitTestTag {
713 /// Encode this tag to `WebRender`'s `ItemTag` format.
714 ///
715 /// Returns `(u64, u16)` suitable for passing to `WebRender`'s `push_hit_test`.
716 #[must_use]
717 pub fn to_item_tag(&self) -> (u64, u16) {
718 match self {
719 Self::DomNode { tag_id } => {
720 // tag.0 = TagId.inner (the sequential counter)
721 // tag.1 = TAG_TYPE_DOM_NODE marker
722 (tag_id.inner, TAG_TYPE_DOM_NODE)
723 }
724 Self::Scrollbar {
725 dom_id,
726 node_id,
727 component,
728 } => {
729 // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
730 let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
731 // tag.1 = TAG_TYPE_SCROLLBAR | component type in lower byte
732 let tag_type = TAG_TYPE_SCROLLBAR | (*component as u16);
733 (tag_value, tag_type)
734 }
735 Self::Cursor {
736 dom_id,
737 node_id,
738 cursor_type,
739 } => {
740 // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
741 let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
742 // tag.1 = TAG_TYPE_CURSOR | cursor type in lower byte
743 let tag_type = TAG_TYPE_CURSOR | (*cursor_type as u16);
744 (tag_value, tag_type)
745 }
746 Self::Selection {
747 dom_id,
748 container_node_id,
749 text_run_index,
750 } => {
751 // tag.0 = DomId (upper 16 bits) | NodeId (middle 32 bits) | text_run_index (lower 16 bits)
752 // AUDIT: mask each field to its bit width so an out-of-range DomId /
753 // NodeId can never bleed into an adjacent field (silent cross-field
754 // corruption). Masking clamps consistently in debug and release —
755 // a >16-bit DomId is absurd but must degrade gracefully, not panic.
756 let dom_bits = (dom_id.inner as u64) & 0xFFFF;
757 let node_bits = (container_node_id.index() as u64) & 0xFFFF_FFFF;
758 let tag_value = (dom_bits << 48) | (node_bits << 16) | u64::from(*text_run_index);
759 (tag_value, TAG_TYPE_SELECTION)
760 }
761 }
762 }
763
764 /// Decode a `WebRender` `ItemTag` back to a typed `HitTestTag`.
765 ///
766 /// Returns `None` if the tag format is invalid or unrecognized.
767 #[must_use]
768 pub fn from_item_tag(tag: (u64, u16)) -> Option<Self> {
769 let (tag_value, tag_type) = tag;
770
771 // Extract tag type from upper byte
772 let type_marker = tag_type & 0xFF00;
773
774 match type_marker {
775 TAG_TYPE_DOM_NODE => {
776 // DOM node tag: tag.0 is the TagId
777 Some(Self::DomNode {
778 tag_id: TagId { inner: tag_value },
779 })
780 }
781 TAG_TYPE_SCROLLBAR => {
782 // Scrollbar tag: decode DomId, NodeId, and component
783 let dom_id = DomId {
784 inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
785 };
786 let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
787 let component_value = (tag_type & 0x00FF) as u8;
788 let component = ScrollbarComponent::from_u8(component_value)?;
789
790 Some(Self::Scrollbar {
791 dom_id,
792 node_id,
793 component,
794 })
795 }
796 TAG_TYPE_CURSOR => {
797 // Cursor tag: decode DomId, NodeId, and cursor type
798 let dom_id = DomId {
799 inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
800 };
801 let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
802 let cursor_value = (tag_type & 0x00FF) as u8;
803 let cursor_type = CursorType::from_u8(cursor_value);
804
805 Some(Self::Cursor {
806 dom_id,
807 node_id,
808 cursor_type,
809 })
810 }
811 TAG_TYPE_SELECTION => {
812 // Selection tag: decode DomId, NodeId, and text run index
813 let dom_id = DomId {
814 inner: ((tag_value >> 48) & 0xFFFF) as usize,
815 };
816 let container_node_id = NodeId::new(((tag_value >> 16) & 0xFFFF_FFFF) as usize);
817 let text_run_index = (tag_value & 0xFFFF) as u16;
818
819 Some(Self::Selection {
820 dom_id,
821 container_node_id,
822 text_run_index,
823 })
824 }
825 _ => {
826 // Unknown tag type - could be a legacy tag or corruption
827 // For backwards compatibility, treat tags with tag_type == 0
828 // as legacy DOM node tags (old format before type markers)
829 if tag_type == 0 {
830 Some(Self::DomNode {
831 tag_id: TagId { inner: tag_value },
832 })
833 } else {
834 None
835 }
836 }
837 }
838 }
839
840 /// Check if this is a DOM node tag
841 #[must_use]
842 pub const fn is_dom_node(&self) -> bool {
843 matches!(self, Self::DomNode { .. })
844 }
845
846 /// Check if this is a scrollbar tag
847 #[must_use]
848 pub const fn is_scrollbar(&self) -> bool {
849 matches!(self, Self::Scrollbar { .. })
850 }
851
852 /// Check if this is a cursor tag
853 #[must_use]
854 pub const fn is_cursor(&self) -> bool {
855 matches!(self, Self::Cursor { .. })
856 }
857
858 /// Check if this is a selection tag
859 #[must_use]
860 pub const fn is_selection(&self) -> bool {
861 matches!(self, Self::Selection { .. })
862 }
863
864 /// Get the `TagId` if this is a DOM node tag
865 #[must_use]
866 pub const fn as_dom_node(&self) -> Option<TagId> {
867 match self {
868 Self::DomNode { tag_id } => Some(*tag_id),
869 _ => None,
870 }
871 }
872
873 /// Get cursor info if this is a cursor tag
874 #[must_use]
875 pub const fn as_cursor(&self) -> Option<(DomId, NodeId, CursorType)> {
876 match self {
877 Self::Cursor {
878 dom_id,
879 node_id,
880 cursor_type,
881 } => Some((*dom_id, *node_id, *cursor_type)),
882 _ => None,
883 }
884 }
885
886 /// Get selection info if this is a selection tag
887 #[must_use]
888 pub const fn as_selection(&self) -> Option<(DomId, NodeId, u16)> {
889 match self {
890 Self::Selection {
891 dom_id,
892 container_node_id,
893 text_run_index,
894 } => Some((*dom_id, *container_node_id, *text_run_index)),
895 _ => None,
896 }
897 }
898
899 /// Get scrollbar info if this is a scrollbar tag
900 #[must_use]
901 pub const fn as_scrollbar(&self) -> Option<(DomId, NodeId, ScrollbarComponent)> {
902 match self {
903 Self::Scrollbar {
904 dom_id,
905 node_id,
906 component,
907 } => Some((*dom_id, *node_id, *component)),
908 _ => None,
909 }
910 }
911}
912
913impl fmt::Display for HitTestTag {
914 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
915 match self {
916 Self::DomNode { tag_id } => {
917 write!(f, "DomNode(tag:{})", tag_id.inner)
918 }
919 Self::Scrollbar {
920 dom_id,
921 node_id,
922 component,
923 } => {
924 write!(
925 f,
926 "Scrollbar(dom:{}, node:{}, {:?})",
927 dom_id.inner,
928 node_id.index(),
929 component
930 )
931 }
932 Self::Cursor {
933 dom_id,
934 node_id,
935 cursor_type,
936 } => {
937 write!(
938 f,
939 "Cursor(dom:{}, node:{}, {:?})",
940 dom_id.inner,
941 node_id.index(),
942 cursor_type
943 )
944 }
945 Self::Selection {
946 dom_id,
947 container_node_id,
948 text_run_index,
949 } => {
950 write!(
951 f,
952 "Selection(dom:{}, container:{}, run:{})",
953 dom_id.inner,
954 container_node_id.index(),
955 text_run_index
956 )
957 }
958 }
959 }
960}
961
962#[cfg(test)]
963#[path = "hit_test_test.rs"]
964mod tests;