1
//! Hit-testing logic for layout windows
2
//!
3
//! This module handles determining which DOM nodes are under the mouse cursor
4
//! and resolving the cursor icon based on CSS cursor properties.
5
//!
6
//! ## Cursor Resolution Algorithm
7
//!
8
//! WebRender returns hit-test results in **front-to-back** order:
9
//! - `depth = 0` is the frontmost/topmost element (closest to the user)
10
//! - Higher depth values are further back in the z-order
11
//!
12
//! The algorithm finds the **frontmost** node that has an explicit CSS `cursor`
13
//! property set. If no node has a cursor property, we check if the node has
14
//! text children and use their cursor property (typically `cursor:text`).
15
//!
16
//! ## Design Principles
17
//!
18
//! 1. **Frontmost priority**: The node closest to the user (lowest depth) takes
19
//!    precedence. This matches browser behavior where a button's cursor:pointer
20
//!    overrides any parent's cursor setting.
21
//!
22
//! 2. **Text-child inheritance**: Text nodes are inline and don't get hit-test areas.
23
//!    Their container inherits the text node's cursor if the container has no explicit
24
//!    cursor property. This shows I-beam cursor over text containers.
25
//!
26
//! 3. **Explicit cursor wins**: If a container has an explicit cursor property
27
//!    (like `cursor:pointer` on a button), it overrides any text-child cursor.
28

            
29
// Re-export FullHitTest for use by other layout modules
30
pub use azul_core::hit_test::FullHitTest;
31
use azul_core::{
32
    dom::{DomId, DomNodeId, NodeId},
33
    hit_test::{HitTest, HitTestItem},
34
    window::MouseCursorType,
35
};
36
use azul_css::props::style::StyleCursor;
37

            
38
use crate::window::LayoutWindow;
39

            
40
/// Result of cursor type hit-testing, determines which mouse cursor to display
41
#[derive(Debug, Clone, Default, PartialEq)]
42
pub struct CursorTypeHitTest {
43
    /// The node that has a non-default cursor property (if any)
44
    pub cursor_node: Option<(DomId, NodeId)>,
45
    /// The mouse cursor type to display
46
    pub cursor_icon: MouseCursorType,
47
}
48

            
49
impl CursorTypeHitTest {
50
    /// Create a new cursor type hit-test from a full hit-test and layout window.
51
    ///
52
    /// Finds the frontmost (lowest depth) node with a cursor property by checking
53
    /// cursor_hit_test_nodes (text runs) and regular_hit_test_nodes (DOM nodes).
54
1
    pub fn new(hit_test: &FullHitTest, layout_window: &LayoutWindow) -> Self {
55
        use azul_core::hit_test_tag::CursorType;
56
        
57
1
        let mut cursor_node = None;
58
1
        let mut cursor_icon = MouseCursorType::Default;
59
        // Start with MAX so any node with a cursor property will be selected
60
1
        let mut best_depth: u32 = u32::MAX;
61

            
62
        // Iterate through all hovered nodes across all DOMs
63
1
        for (dom_id, hit_nodes) in hit_test.hovered_nodes.iter() {
64
            // Get the layout result for this DOM
65
            let layout_result = match layout_window.get_layout_result(dom_id) {
66
                Some(lr) => lr,
67
                None => continue,
68
            };
69

            
70
            let styled_dom = &layout_result.styled_dom;
71
            let node_data_container = styled_dom.node_data.as_container();
72
            let styled_nodes = styled_dom.styled_nodes.as_container();
73

            
74
            // Check cursor_hit_test_nodes (direct text run hits with cursor
75
            // type encoded in the tag, no CSS lookup needed)
76
            for (node_id, cursor_hit) in hit_nodes.cursor_hit_test_nodes.iter() {
77
                let node_depth = cursor_hit.hit_depth;
78
                
79
                // Only consider if it's in front of our current best
80
                if node_depth >= best_depth {
81
                    continue;
82
                }
83
                
84
                // Convert CursorType to MouseCursorType
85
                let mouse_cursor = translate_cursor_type(cursor_hit.cursor_type);
86
                
87
                // Only use this cursor if it's not the default
88
                // (allows containers behind text to show their cursor if text has default)
89
                if mouse_cursor != MouseCursorType::Default {
90
                    cursor_node = Some((*dom_id, *node_id));
91
                    cursor_icon = mouse_cursor;
92
                    best_depth = node_depth;
93
                }
94
            }
95

            
96
            // Check regular_hit_test_nodes (DOM nodes with CSS cursor property)
97
            for (node_id, hit_item) in hit_nodes.regular_hit_test_nodes.iter() {
98
                let node_depth = hit_item.hit_depth;
99

            
100
                // Only consider this node if it's in front of our current best
101
                if node_depth >= best_depth {
102
                    continue;
103
                }
104

            
105
                // CHECKED access: hit-test results can reference a PREVIOUS
106
                // generation of a VirtualView child DOM — the child is rebuilt
107
                // in place with fresh (possibly fewer) NodeIds while the hover
108
                // state / CPU hit-tester still hold last frame's ids (e.g.
109
                // panning the MapWidget shrinks the tile grid). Blind indexing
110
                // panicked here ("len is 25 but the index is 27"); a stale id is
111
                // skipped instead — the next pointer move re-hit-tests against
112
                // the fresh tree.
113
                let (Some(node_data), Some(styled_node)) = (
114
                    node_data_container.get(*node_id),
115
                    styled_nodes.get(*node_id),
116
                ) else {
117
                    continue;
118
                };
119

            
120
                // Query the CSS cursor property for this node
121
                let cursor_prop = styled_dom.get_css_property_cache().get_cursor(
122
                    node_data,
123
                    node_id,
124
                    &styled_node.styled_node_state,
125
                );
126
                
127
                // If this node has an explicit cursor property, use it
128
                if let Some(cursor_prop) = cursor_prop {
129
                    let css_cursor = cursor_prop.get_property().copied().unwrap_or_default();
130
                    cursor_node = Some((*dom_id, *node_id));
131
                    cursor_icon = translate_cursor(css_cursor);
132
                    best_depth = node_depth;
133
                } else {
134
                    // No explicit `cursor`: editable text (contenteditable / a
135
                    // <textarea>) defaults to the I-beam, like browsers — so a
136
                    // multi-line textarea shows the text cursor on hover even
137
                    // without `cursor: text` in CSS. (A single-line input already
138
                    // gets the I-beam from its text-run cursor tag / explicit CSS;
139
                    // this makes them consistent. Does NOT affect the text_input
140
                    // widget, which sets cursor:text explicitly and so takes the
141
                    // branch above.)
142
                    if node_data.is_contenteditable()
143
                        || matches!(node_data.node_type, azul_core::dom::NodeType::TextArea)
144
                    {
145
                        cursor_node = Some((*dom_id, *node_id));
146
                        cursor_icon = MouseCursorType::Text;
147
                        best_depth = node_depth;
148
                    }
149
                }
150
            }
151
        }
152

            
153
1
        Self {
154
1
            cursor_node,
155
1
            cursor_icon,
156
1
        }
157
1
    }
158
}
159

            
160
/// Translate CursorType (from hit-test tag) to MouseCursorType
161
fn translate_cursor_type(cursor_type: azul_core::hit_test_tag::CursorType) -> MouseCursorType {
162
    use azul_core::hit_test_tag::CursorType;
163
    
164
    match cursor_type {
165
        CursorType::Default => MouseCursorType::Default,
166
        CursorType::Pointer => MouseCursorType::Hand,
167
        CursorType::Text => MouseCursorType::Text,
168
        CursorType::Crosshair => MouseCursorType::Crosshair,
169
        CursorType::Move => MouseCursorType::Move,
170
        CursorType::NotAllowed => MouseCursorType::NotAllowed,
171
        CursorType::Grab => MouseCursorType::Grab,
172
        CursorType::Grabbing => MouseCursorType::Grabbing,
173
        CursorType::EResize => MouseCursorType::EResize,
174
        CursorType::WResize => MouseCursorType::WResize,
175
        CursorType::NResize => MouseCursorType::NResize,
176
        CursorType::SResize => MouseCursorType::SResize,
177
        CursorType::EwResize => MouseCursorType::EwResize,
178
        CursorType::NsResize => MouseCursorType::NsResize,
179
        CursorType::NeswResize => MouseCursorType::NeswResize,
180
        CursorType::NwseResize => MouseCursorType::NwseResize,
181
        CursorType::ColResize => MouseCursorType::ColResize,
182
        CursorType::RowResize => MouseCursorType::RowResize,
183
        CursorType::Wait => MouseCursorType::Wait,
184
        CursorType::Help => MouseCursorType::Help,
185
        CursorType::Progress => MouseCursorType::Progress,
186
    }
187
}
188

            
189
/// Translate CSS cursor value to MouseCursorType
190
5
fn translate_cursor(cursor: StyleCursor) -> MouseCursorType {
191
    use azul_css::props::style::effects::StyleCursor;
192

            
193
5
    match cursor {
194
1
        StyleCursor::Default => MouseCursorType::Default,
195
1
        StyleCursor::Crosshair => MouseCursorType::Crosshair,
196
1
        StyleCursor::Pointer => MouseCursorType::Hand,
197
1
        StyleCursor::Move => MouseCursorType::Move,
198
1
        StyleCursor::Text => MouseCursorType::Text,
199
        StyleCursor::Wait => MouseCursorType::Wait,
200
        StyleCursor::Help => MouseCursorType::Help,
201
        StyleCursor::Progress => MouseCursorType::Progress,
202
        StyleCursor::ContextMenu => MouseCursorType::ContextMenu,
203
        StyleCursor::Cell => MouseCursorType::Cell,
204
        StyleCursor::VerticalText => MouseCursorType::VerticalText,
205
        StyleCursor::Alias => MouseCursorType::Alias,
206
        StyleCursor::Copy => MouseCursorType::Copy,
207
        StyleCursor::Grab => MouseCursorType::Grab,
208
        StyleCursor::Grabbing => MouseCursorType::Grabbing,
209
        StyleCursor::AllScroll => MouseCursorType::AllScroll,
210
        StyleCursor::ZoomIn => MouseCursorType::ZoomIn,
211
        StyleCursor::ZoomOut => MouseCursorType::ZoomOut,
212
        StyleCursor::EResize => MouseCursorType::EResize,
213
        StyleCursor::NResize => MouseCursorType::NResize,
214
        StyleCursor::SResize => MouseCursorType::SResize,
215
        StyleCursor::SeResize => MouseCursorType::SeResize,
216
        StyleCursor::WResize => MouseCursorType::WResize,
217
        StyleCursor::EwResize => MouseCursorType::EwResize,
218
        StyleCursor::NsResize => MouseCursorType::NsResize,
219
        StyleCursor::NeswResize => MouseCursorType::NeswResize,
220
        StyleCursor::NwseResize => MouseCursorType::NwseResize,
221
        StyleCursor::ColResize => MouseCursorType::ColResize,
222
        StyleCursor::RowResize => MouseCursorType::RowResize,
223
        StyleCursor::Unset => MouseCursorType::Default,
224
    }
225
5
}
226

            
227
#[cfg(test)]
228
mod tests {
229
    use super::*;
230
    use azul_core::dom::DomNodeId;
231
    use azul_core::dom::OptionDomNodeId;
232

            
233
    #[test]
234
1
    fn test_full_hit_test_empty() {
235
1
        let hit_test = FullHitTest::empty(None);
236
1
        assert!(hit_test.is_empty());
237
1
        assert!(hit_test.focused_node.is_none());
238
1
    }
239

            
240
    #[test]
241
1
    fn test_full_hit_test_with_focused_node() {
242
1
        let focused = DomNodeId {
243
1
            dom: DomId { inner: 0 },
244
1
            node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
245
1
                NodeId::new(5),
246
1
            )),
247
1
        };
248
1
        let hit_test = FullHitTest::empty(Some(focused));
249
1
        assert!(hit_test.is_empty()); // No hovered nodes
250
1
        assert_eq!(
251
            hit_test.focused_node,
252
1
            OptionDomNodeId::Some(DomNodeId {
253
1
                dom: DomId { inner: 0 },
254
1
                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
255
1
                    NodeId::new(5),
256
1
                )),
257
1
            })
258
        );
259
1
    }
260

            
261
    #[test]
262
1
    fn test_cursor_type_hit_test_default() {
263
1
        let cursor_test = CursorTypeHitTest::default();
264
1
        assert_eq!(cursor_test.cursor_icon, MouseCursorType::Default);
265
1
        assert!(cursor_test.cursor_node.is_none());
266
1
    }
267

            
268
    #[test]
269
1
    fn test_translate_cursor_mapping() {
270
        use azul_css::props::style::effects::StyleCursor;
271

            
272
1
        assert_eq!(
273
1
            translate_cursor(StyleCursor::Default),
274
            MouseCursorType::Default
275
        );
276
1
        assert_eq!(
277
1
            translate_cursor(StyleCursor::Pointer),
278
            MouseCursorType::Hand
279
        );
280
1
        assert_eq!(translate_cursor(StyleCursor::Text), MouseCursorType::Text);
281
1
        assert_eq!(translate_cursor(StyleCursor::Move), MouseCursorType::Move);
282
1
        assert_eq!(
283
1
            translate_cursor(StyleCursor::Crosshair),
284
            MouseCursorType::Crosshair
285
        );
286
1
    }
287
}