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
                // Query the CSS cursor property for this node
106
                let cursor_prop = styled_dom.get_css_property_cache().get_cursor(
107
                    &node_data_container[*node_id],
108
                    node_id,
109
                    &styled_nodes[*node_id].styled_node_state,
110
                );
111
                
112
                // If this node has an explicit cursor property, use it
113
                if let Some(cursor_prop) = cursor_prop {
114
                    let css_cursor = cursor_prop.get_property().copied().unwrap_or_default();
115
                    cursor_node = Some((*dom_id, *node_id));
116
                    cursor_icon = translate_cursor(css_cursor);
117
                    best_depth = node_depth;
118
                }
119
            }
120
        }
121

            
122
1
        Self {
123
1
            cursor_node,
124
1
            cursor_icon,
125
1
        }
126
1
    }
127
}
128

            
129
/// Translate CursorType (from hit-test tag) to MouseCursorType
130
fn translate_cursor_type(cursor_type: azul_core::hit_test_tag::CursorType) -> MouseCursorType {
131
    use azul_core::hit_test_tag::CursorType;
132
    
133
    match cursor_type {
134
        CursorType::Default => MouseCursorType::Default,
135
        CursorType::Pointer => MouseCursorType::Hand,
136
        CursorType::Text => MouseCursorType::Text,
137
        CursorType::Crosshair => MouseCursorType::Crosshair,
138
        CursorType::Move => MouseCursorType::Move,
139
        CursorType::NotAllowed => MouseCursorType::NotAllowed,
140
        CursorType::Grab => MouseCursorType::Grab,
141
        CursorType::Grabbing => MouseCursorType::Grabbing,
142
        CursorType::EResize => MouseCursorType::EResize,
143
        CursorType::WResize => MouseCursorType::WResize,
144
        CursorType::NResize => MouseCursorType::NResize,
145
        CursorType::SResize => MouseCursorType::SResize,
146
        CursorType::EwResize => MouseCursorType::EwResize,
147
        CursorType::NsResize => MouseCursorType::NsResize,
148
        CursorType::NeswResize => MouseCursorType::NeswResize,
149
        CursorType::NwseResize => MouseCursorType::NwseResize,
150
        CursorType::ColResize => MouseCursorType::ColResize,
151
        CursorType::RowResize => MouseCursorType::RowResize,
152
        CursorType::Wait => MouseCursorType::Wait,
153
        CursorType::Help => MouseCursorType::Help,
154
        CursorType::Progress => MouseCursorType::Progress,
155
    }
156
}
157

            
158
/// Translate CSS cursor value to MouseCursorType
159
5
fn translate_cursor(cursor: StyleCursor) -> MouseCursorType {
160
    use azul_css::props::style::effects::StyleCursor;
161

            
162
5
    match cursor {
163
1
        StyleCursor::Default => MouseCursorType::Default,
164
1
        StyleCursor::Crosshair => MouseCursorType::Crosshair,
165
1
        StyleCursor::Pointer => MouseCursorType::Hand,
166
1
        StyleCursor::Move => MouseCursorType::Move,
167
1
        StyleCursor::Text => MouseCursorType::Text,
168
        StyleCursor::Wait => MouseCursorType::Wait,
169
        StyleCursor::Help => MouseCursorType::Help,
170
        StyleCursor::Progress => MouseCursorType::Progress,
171
        StyleCursor::ContextMenu => MouseCursorType::ContextMenu,
172
        StyleCursor::Cell => MouseCursorType::Cell,
173
        StyleCursor::VerticalText => MouseCursorType::VerticalText,
174
        StyleCursor::Alias => MouseCursorType::Alias,
175
        StyleCursor::Copy => MouseCursorType::Copy,
176
        StyleCursor::Grab => MouseCursorType::Grab,
177
        StyleCursor::Grabbing => MouseCursorType::Grabbing,
178
        StyleCursor::AllScroll => MouseCursorType::AllScroll,
179
        StyleCursor::ZoomIn => MouseCursorType::ZoomIn,
180
        StyleCursor::ZoomOut => MouseCursorType::ZoomOut,
181
        StyleCursor::EResize => MouseCursorType::EResize,
182
        StyleCursor::NResize => MouseCursorType::NResize,
183
        StyleCursor::SResize => MouseCursorType::SResize,
184
        StyleCursor::SeResize => MouseCursorType::SeResize,
185
        StyleCursor::WResize => MouseCursorType::WResize,
186
        StyleCursor::EwResize => MouseCursorType::EwResize,
187
        StyleCursor::NsResize => MouseCursorType::NsResize,
188
        StyleCursor::NeswResize => MouseCursorType::NeswResize,
189
        StyleCursor::NwseResize => MouseCursorType::NwseResize,
190
        StyleCursor::ColResize => MouseCursorType::ColResize,
191
        StyleCursor::RowResize => MouseCursorType::RowResize,
192
        StyleCursor::Unset => MouseCursorType::Default,
193
    }
194
5
}
195

            
196
#[cfg(test)]
197
mod tests {
198
    use super::*;
199
    use azul_core::dom::DomNodeId;
200
    use azul_core::dom::OptionDomNodeId;
201

            
202
    #[test]
203
1
    fn test_full_hit_test_empty() {
204
1
        let hit_test = FullHitTest::empty(None);
205
1
        assert!(hit_test.is_empty());
206
1
        assert!(hit_test.focused_node.is_none());
207
1
    }
208

            
209
    #[test]
210
1
    fn test_full_hit_test_with_focused_node() {
211
1
        let focused = DomNodeId {
212
1
            dom: DomId { inner: 0 },
213
1
            node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
214
1
                NodeId::new(5),
215
1
            )),
216
1
        };
217
1
        let hit_test = FullHitTest::empty(Some(focused));
218
1
        assert!(hit_test.is_empty()); // No hovered nodes
219
1
        assert_eq!(
220
            hit_test.focused_node,
221
1
            OptionDomNodeId::Some(DomNodeId {
222
1
                dom: DomId { inner: 0 },
223
1
                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
224
1
                    NodeId::new(5),
225
1
                )),
226
1
            })
227
        );
228
1
    }
229

            
230
    #[test]
231
1
    fn test_cursor_type_hit_test_default() {
232
1
        let cursor_test = CursorTypeHitTest::default();
233
1
        assert_eq!(cursor_test.cursor_icon, MouseCursorType::Default);
234
1
        assert!(cursor_test.cursor_node.is_none());
235
1
    }
236

            
237
    #[test]
238
1
    fn test_translate_cursor_mapping() {
239
        use azul_css::props::style::effects::StyleCursor;
240

            
241
1
        assert_eq!(
242
1
            translate_cursor(StyleCursor::Default),
243
            MouseCursorType::Default
244
        );
245
1
        assert_eq!(
246
1
            translate_cursor(StyleCursor::Pointer),
247
            MouseCursorType::Hand
248
        );
249
1
        assert_eq!(translate_cursor(StyleCursor::Text), MouseCursorType::Text);
250
1
        assert_eq!(translate_cursor(StyleCursor::Move), MouseCursorType::Move);
251
1
        assert_eq!(
252
1
            translate_cursor(StyleCursor::Crosshair),
253
            MouseCursorType::Crosshair
254
        );
255
1
    }
256
}