Skip to main content

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;