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(Copy, Debug, Clone, Default, PartialEq, Eq)]
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
28
    pub fn new(hit_test: &FullHitTest, layout_window: &LayoutWindow) -> Self {
55
        use azul_core::hit_test::CursorType;
56
        
57
28
        let mut cursor_node = None;
58
28
        let mut cursor_icon = MouseCursorType::Default;
59
        // Start with MAX so any node with a cursor property will be selected
60
28
        let mut best_depth: u32 = u32::MAX;
61

            
62
        // Iterate through all hovered nodes across all DOMs
63
58
        for (dom_id, hit_nodes) in &hit_test.hovered_nodes {
64
            // Get the layout result for this DOM
65
30
            let Some(layout_result) = layout_window.get_layout_result(dom_id) else {
66
3
                continue;
67
            };
68

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

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

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

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

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

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

            
152
28
        Self {
153
28
            cursor_node,
154
28
            cursor_icon,
155
28
        }
156
28
    }
157
}
158

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

            
188
/// Translate CSS cursor value to `MouseCursorType`
189
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
190
104
const fn translate_cursor(cursor: StyleCursor) -> MouseCursorType {
191
    use azul_css::props::style::effects::StyleCursor;
192

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

            
289
#[cfg(test)]
290
mod autotest_generated {
291
    use std::collections::{BTreeMap, HashMap};
292

            
293
    use azul_core::{
294
        dom::{Dom, NodeData, NodeType},
295
        geom::{LogicalPosition, LogicalRect},
296
        hit_test::{CursorHitTestItem, CursorType},
297
        styled_dom::StyledDom,
298
    };
299
    use rust_fontconfig::FcFontCache;
300

            
301
    use super::*;
302
    use crate::{
303
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
304
        window::DomLayoutResult,
305
    };
306

            
307
    // ------------------------------------------------------------------
308
    // Fixture
309
    // ------------------------------------------------------------------
310

            
311
    // Flatten indices of `fixture_dom()` (pre-order, body first).
312
    const BODY: usize = 0;
313
    /// explicit `cursor: pointer`
314
    const POINTER_DIV: usize = 1;
315
    /// explicit `cursor: crosshair`
316
    const CROSSHAIR_DIV: usize = 2;
317
    /// no cursor property at all
318
    const PLAIN_DIV: usize = 3;
319
    /// no cursor property, but `contenteditable`
320
    const EDITABLE_DIV: usize = 4;
321
    /// UA stylesheet gives it `cursor: text`
322
    const TEXTAREA: usize = 5;
323
    /// explicit `cursor: default`
324
    const DEFAULT_CURSOR_DIV: usize = 6;
325
    /// UA stylesheet gives it `cursor: pointer`
326
    const BUTTON: usize = 7;
327
    /// One past the last node — every id >= this is stale.
328
    const NODE_COUNT: usize = 8;
329

            
330
    /// A DOM covering every branch of `CursorTypeHitTest::new`: explicit CSS
331
    /// cursors, a UA-stylesheet cursor, a node with no cursor at all, and the
332
    /// two "editable text implies I-beam" node kinds. All cursor-carrying nodes
333
    /// are *leaves* and *siblings*, so CSS inheritance (cursor is an inherited
334
    /// property) can never make one of them bleed into another.
335
    fn fixture_dom() -> StyledDom {
336
        let dom = Dom::create_body()
337
            .with_child(Dom::create_div().with_css("cursor: pointer;"))
338
            .with_child(Dom::create_div().with_css("cursor: crosshair;"))
339
            .with_child(Dom::create_div())
340
            .with_child(Dom::create_div().with_contenteditable(true))
341
            .with_child(Dom::create_node(NodeType::TextArea))
342
            .with_child(Dom::create_div().with_css("cursor: default;"))
343
            .with_child(Dom::create_from_data(NodeData::create_button_no_a11y()));
344
        StyledDom::create_from_dom(dom)
345
    }
346

            
347
    /// A `DomLayoutResult` with an *empty* layout tree: `CursorTypeHitTest::new`
348
    /// only ever reads `styled_dom`, so no real layout (and no font) is needed.
349
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
350
        DomLayoutResult {
351
            styled_dom,
352
            layout_tree: LayoutTree {
353
                nodes: Vec::new(),
354
                warm: Vec::new(),
355
                cold: Vec::new(),
356
                root: 0,
357
                dom_to_layout: BTreeMap::new(),
358
                children_arena: Vec::new(),
359
                children_offsets: Vec::new(),
360
                subtree_needs_intrinsic: Vec::new(),
361
            },
362
            calculated_positions: Vec::new(),
363
            viewport: LogicalRect::zero(),
364
            display_list: std::sync::Arc::new(DisplayList::default()),
365
            scroll_ids: HashMap::new(),
366
            scroll_id_to_node_id: HashMap::new(),
367
        }
368
    }
369

            
370
    /// A window holding the fixture under every one of `dom_ids`.
371
    fn window_with(dom_ids: &[DomId]) -> LayoutWindow {
372
        let mut lw = LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
373
        for dom_id in dom_ids {
374
            lw.layout_results.insert(*dom_id, layout_result(fixture_dom()));
375
        }
376
        lw
377
    }
378

            
379
    fn dom(inner: usize) -> DomId {
380
        DomId { inner }
381
    }
382

            
383
    /// `(node index, hit depth)` regular hits + `(node index, cursor type, hit
384
    /// depth)` text-run hits, grouped per DOM.
385
    type DomHits<'a> = (DomId, &'a [(usize, u32)], &'a [(usize, CursorType, u32)]);
386

            
387
    fn make_hit_test(entries: &[DomHits<'_>]) -> FullHitTest {
388
        let mut full = FullHitTest::empty(None);
389
        for (dom_id, regular, cursors) in entries {
390
            let mut ht = HitTest::empty();
391
            for (idx, depth) in *regular {
392
                ht.regular_hit_test_nodes.insert(
393
                    NodeId::new(*idx),
394
                    HitTestItem {
395
                        point_in_viewport: LogicalPosition::zero(),
396
                        point_relative_to_item: LogicalPosition::zero(),
397
                        is_focusable: false,
398
                        is_virtual_view_hit: None,
399
                        hit_depth: *depth,
400
                    },
401
                );
402
            }
403
            for (idx, cursor_type, depth) in *cursors {
404
                ht.cursor_hit_test_nodes.insert(
405
                    NodeId::new(*idx),
406
                    CursorHitTestItem {
407
                        cursor_type: *cursor_type,
408
                        hit_depth: *depth,
409
                        point_in_viewport: LogicalPosition::zero(),
410
                    },
411
                );
412
            }
413
            full.hovered_nodes.insert(*dom_id, ht);
414
        }
415
        full
416
    }
417

            
418
    /// Hit-test the fixture (loaded as the root DOM) and return the resolved cursor.
419
    fn resolve(regular: &[(usize, u32)], cursors: &[(usize, CursorType, u32)]) -> CursorTypeHitTest {
420
        let lw = window_with(&[DomId::ROOT_ID]);
421
        let hit = make_hit_test(&[(DomId::ROOT_ID, regular, cursors)]);
422
        CursorTypeHitTest::new(&hit, &lw)
423
    }
424

            
425
    fn root_node(idx: usize) -> Option<(DomId, NodeId)> {
426
        Some((DomId::ROOT_ID, NodeId::new(idx)))
427
    }
428

            
429
    /// The one invariant every result must satisfy: no node selected => no cursor.
430
    fn assert_invariant(result: &CursorTypeHitTest) {
431
        if result.cursor_node.is_none() {
432
            assert_eq!(
433
                result.cursor_icon,
434
                MouseCursorType::Default,
435
                "cursor_node == None must imply the default icon"
436
            );
437
        }
438
    }
439

            
440
    const ALL_CURSOR_TYPES: [CursorType; 21] = [
441
        CursorType::Default,
442
        CursorType::Pointer,
443
        CursorType::Text,
444
        CursorType::Crosshair,
445
        CursorType::Move,
446
        CursorType::NotAllowed,
447
        CursorType::Grab,
448
        CursorType::Grabbing,
449
        CursorType::EResize,
450
        CursorType::WResize,
451
        CursorType::NResize,
452
        CursorType::SResize,
453
        CursorType::EwResize,
454
        CursorType::NsResize,
455
        CursorType::NeswResize,
456
        CursorType::NwseResize,
457
        CursorType::ColResize,
458
        CursorType::RowResize,
459
        CursorType::Wait,
460
        CursorType::Help,
461
        CursorType::Progress,
462
    ];
463

            
464
    const ALL_STYLE_CURSORS: [StyleCursor; 30] = [
465
        StyleCursor::Alias,
466
        StyleCursor::AllScroll,
467
        StyleCursor::Cell,
468
        StyleCursor::ColResize,
469
        StyleCursor::ContextMenu,
470
        StyleCursor::Copy,
471
        StyleCursor::Crosshair,
472
        StyleCursor::Default,
473
        StyleCursor::EResize,
474
        StyleCursor::EwResize,
475
        StyleCursor::Grab,
476
        StyleCursor::Grabbing,
477
        StyleCursor::Help,
478
        StyleCursor::Move,
479
        StyleCursor::NResize,
480
        StyleCursor::NsResize,
481
        StyleCursor::NeswResize,
482
        StyleCursor::NwseResize,
483
        StyleCursor::Pointer,
484
        StyleCursor::Progress,
485
        StyleCursor::RowResize,
486
        StyleCursor::SResize,
487
        StyleCursor::SeResize,
488
        StyleCursor::Text,
489
        StyleCursor::Unset,
490
        StyleCursor::VerticalText,
491
        StyleCursor::WResize,
492
        StyleCursor::Wait,
493
        StyleCursor::ZoomIn,
494
        StyleCursor::ZoomOut,
495
    ];
496

            
497
    // ==================================================================
498
    // Fixture preconditions — if these fail, every assertion below is
499
    // meaningless, so they are asserted separately and up front.
500
    // ==================================================================
501

            
502
    #[test]
503
    fn fixture_node_ids_and_cursor_properties_are_what_the_tests_assume() {
504
        let styled = fixture_dom();
505
        let node_data = styled.node_data.as_container();
506
        assert_eq!(
507
            node_data.internal.len(),
508
            NODE_COUNT,
509
            "fixture flatten order changed — the node index constants are stale"
510
        );
511
        assert!(matches!(node_data.internal[BODY].node_type, NodeType::Body));
512
        assert!(node_data.internal[EDITABLE_DIV].is_contenteditable());
513
        assert!(matches!(
514
            node_data.internal[TEXTAREA].node_type,
515
            NodeType::TextArea
516
        ));
517
        assert!(matches!(
518
            node_data.internal[BUTTON].node_type,
519
            NodeType::Button
520
        ));
521

            
522
        // The CSS cascade must actually deliver `cursor` to the property cache,
523
        // otherwise CursorTypeHitTest can never see it.
524
        let styled_nodes = styled.styled_nodes.as_container();
525
        let cache = styled.get_css_property_cache();
526
        let cursor_of = |idx: usize| {
527
            let nid = NodeId::new(idx);
528
            cache
529
                .get_cursor(
530
                    &node_data.internal[idx],
531
                    &nid,
532
                    &styled_nodes.internal[idx].styled_node_state,
533
                )
534
                .and_then(|p| p.get_property().copied())
535
        };
536
        assert_eq!(cursor_of(POINTER_DIV), Some(StyleCursor::Pointer));
537
        assert_eq!(cursor_of(CROSSHAIR_DIV), Some(StyleCursor::Crosshair));
538
        assert_eq!(cursor_of(DEFAULT_CURSOR_DIV), Some(StyleCursor::Default));
539
        assert_eq!(
540
            cursor_of(PLAIN_DIV),
541
            None,
542
            "a plain div must have no cursor property, or the contenteditable / \
543
             text-child fallbacks are unreachable"
544
        );
545
        assert_eq!(
546
            cursor_of(EDITABLE_DIV),
547
            None,
548
            "contenteditable must NOT get a cursor from CSS — the I-beam has to \
549
             come from the node-data fallback branch"
550
        );
551
    }
552

            
553
    // ==================================================================
554
    // translate_cursor_type — total, injective, u8-saturating
555
    // ==================================================================
556

            
557
    #[test]
558
    fn translate_cursor_type_maps_every_variant_to_its_documented_icon() {
559
        let expected = [
560
            (CursorType::Default, MouseCursorType::Default),
561
            (CursorType::Pointer, MouseCursorType::Hand),
562
            (CursorType::Text, MouseCursorType::Text),
563
            (CursorType::Crosshair, MouseCursorType::Crosshair),
564
            (CursorType::Move, MouseCursorType::Move),
565
            (CursorType::NotAllowed, MouseCursorType::NotAllowed),
566
            (CursorType::Grab, MouseCursorType::Grab),
567
            (CursorType::Grabbing, MouseCursorType::Grabbing),
568
            (CursorType::EResize, MouseCursorType::EResize),
569
            (CursorType::WResize, MouseCursorType::WResize),
570
            (CursorType::NResize, MouseCursorType::NResize),
571
            (CursorType::SResize, MouseCursorType::SResize),
572
            (CursorType::EwResize, MouseCursorType::EwResize),
573
            (CursorType::NsResize, MouseCursorType::NsResize),
574
            (CursorType::NeswResize, MouseCursorType::NeswResize),
575
            (CursorType::NwseResize, MouseCursorType::NwseResize),
576
            (CursorType::ColResize, MouseCursorType::ColResize),
577
            (CursorType::RowResize, MouseCursorType::RowResize),
578
            (CursorType::Wait, MouseCursorType::Wait),
579
            (CursorType::Help, MouseCursorType::Help),
580
            (CursorType::Progress, MouseCursorType::Progress),
581
        ];
582
        assert_eq!(expected.len(), ALL_CURSOR_TYPES.len());
583
        for (input, want) in expected {
584
            assert_eq!(translate_cursor_type(input), want, "{input:?}");
585
        }
586
    }
587

            
588
    #[test]
589
    fn translate_cursor_type_is_injective_so_no_hit_tag_is_aliased() {
590
        // Two distinct hit-test tags must never collapse onto the same icon:
591
        // `CursorTypeHitTest::new` treats `MouseCursorType::Default` as "no
592
        // cursor here", so an accidental alias onto Default would silently drop
593
        // a text run's cursor.
594
        let mut seen = std::collections::BTreeSet::new();
595
        for ct in ALL_CURSOR_TYPES {
596
            assert!(
597
                seen.insert(translate_cursor_type(ct)),
598
                "{ct:?} aliases an icon already produced by another CursorType"
599
            );
600
        }
601
        assert_eq!(seen.len(), ALL_CURSOR_TYPES.len());
602
    }
603

            
604
    #[test]
605
    fn only_cursor_type_default_translates_to_the_default_icon() {
606
        for ct in ALL_CURSOR_TYPES {
607
            let is_default_icon = translate_cursor_type(ct) == MouseCursorType::Default;
608
            assert_eq!(
609
                is_default_icon,
610
                ct == CursorType::Default,
611
                "{ct:?}: a non-Default CursorType that maps to the Default icon \
612
                 would be silently skipped by CursorTypeHitTest::new"
613
            );
614
        }
615
    }
616

            
617
    #[test]
618
    fn cursor_type_round_trips_through_its_u8_discriminant() {
619
        for ct in ALL_CURSOR_TYPES {
620
            assert_eq!(CursorType::from_u8(ct as u8), ct, "{ct:?}");
621
        }
622
    }
623

            
624
    #[test]
625
    fn every_u8_tag_byte_decodes_and_translates_without_panicking() {
626
        // The cursor type is carried in the low byte of a WebRender ItemTag, so
627
        // any of the 256 byte values can reach `from_u8` from a stale/corrupt
628
        // display list. Out-of-range bytes must saturate to Default, not panic.
629
        for byte in 0u8..=u8::MAX {
630
            let ct = CursorType::from_u8(byte);
631
            let icon = translate_cursor_type(ct);
632
            if byte > 20 {
633
                assert_eq!(ct, CursorType::Default, "byte {byte} should saturate");
634
                assert_eq!(icon, MouseCursorType::Default, "byte {byte}");
635
            } else {
636
                assert_eq!(ct as u8, byte, "byte {byte} must decode to itself");
637
            }
638
        }
639
    }
640

            
641
    // ==================================================================
642
    // translate_cursor — total over StyleCursor
643
    // ==================================================================
644

            
645
    #[test]
646
    fn translate_cursor_maps_every_style_cursor_to_its_documented_icon() {
647
        let expected = [
648
            (StyleCursor::Alias, MouseCursorType::Alias),
649
            (StyleCursor::AllScroll, MouseCursorType::AllScroll),
650
            (StyleCursor::Cell, MouseCursorType::Cell),
651
            (StyleCursor::ColResize, MouseCursorType::ColResize),
652
            (StyleCursor::ContextMenu, MouseCursorType::ContextMenu),
653
            (StyleCursor::Copy, MouseCursorType::Copy),
654
            (StyleCursor::Crosshair, MouseCursorType::Crosshair),
655
            (StyleCursor::Default, MouseCursorType::Default),
656
            (StyleCursor::EResize, MouseCursorType::EResize),
657
            (StyleCursor::EwResize, MouseCursorType::EwResize),
658
            (StyleCursor::Grab, MouseCursorType::Grab),
659
            (StyleCursor::Grabbing, MouseCursorType::Grabbing),
660
            (StyleCursor::Help, MouseCursorType::Help),
661
            (StyleCursor::Move, MouseCursorType::Move),
662
            (StyleCursor::NResize, MouseCursorType::NResize),
663
            (StyleCursor::NsResize, MouseCursorType::NsResize),
664
            (StyleCursor::NeswResize, MouseCursorType::NeswResize),
665
            (StyleCursor::NwseResize, MouseCursorType::NwseResize),
666
            (StyleCursor::Pointer, MouseCursorType::Hand),
667
            (StyleCursor::Progress, MouseCursorType::Progress),
668
            (StyleCursor::RowResize, MouseCursorType::RowResize),
669
            (StyleCursor::SResize, MouseCursorType::SResize),
670
            (StyleCursor::SeResize, MouseCursorType::SeResize),
671
            (StyleCursor::Text, MouseCursorType::Text),
672
            (StyleCursor::Unset, MouseCursorType::Default),
673
            (StyleCursor::VerticalText, MouseCursorType::VerticalText),
674
            (StyleCursor::WResize, MouseCursorType::WResize),
675
            (StyleCursor::Wait, MouseCursorType::Wait),
676
            (StyleCursor::ZoomIn, MouseCursorType::ZoomIn),
677
            (StyleCursor::ZoomOut, MouseCursorType::ZoomOut),
678
        ];
679
        assert_eq!(expected.len(), ALL_STYLE_CURSORS.len());
680
        for (input, want) in expected {
681
            assert_eq!(translate_cursor(input), want, "{input:?}");
682
        }
683
    }
684

            
685
    #[test]
686
    fn default_and_unset_are_the_only_style_cursors_that_yield_the_default_icon() {
687
        for sc in ALL_STYLE_CURSORS {
688
            let is_default_icon = translate_cursor(sc) == MouseCursorType::Default;
689
            let expected = matches!(sc, StyleCursor::Default | StyleCursor::Unset);
690
            assert_eq!(is_default_icon, expected, "{sc:?}");
691
        }
692
    }
693

            
694
    #[test]
695
    fn the_two_translators_agree_on_the_cursors_they_both_understand() {
696
        // A text run's cursor comes from the hit-test tag (CursorType) while its
697
        // container's comes from CSS (StyleCursor). If the two tables disagreed,
698
        // the icon would flicker depending on which one won the depth race.
699
        let shared = [
700
            (CursorType::Default, StyleCursor::Default),
701
            (CursorType::Pointer, StyleCursor::Pointer),
702
            (CursorType::Text, StyleCursor::Text),
703
            (CursorType::Crosshair, StyleCursor::Crosshair),
704
            (CursorType::Move, StyleCursor::Move),
705
            (CursorType::Grab, StyleCursor::Grab),
706
            (CursorType::Grabbing, StyleCursor::Grabbing),
707
            (CursorType::EResize, StyleCursor::EResize),
708
            (CursorType::WResize, StyleCursor::WResize),
709
            (CursorType::NResize, StyleCursor::NResize),
710
            (CursorType::SResize, StyleCursor::SResize),
711
            (CursorType::EwResize, StyleCursor::EwResize),
712
            (CursorType::NsResize, StyleCursor::NsResize),
713
            (CursorType::NeswResize, StyleCursor::NeswResize),
714
            (CursorType::NwseResize, StyleCursor::NwseResize),
715
            (CursorType::ColResize, StyleCursor::ColResize),
716
            (CursorType::RowResize, StyleCursor::RowResize),
717
            (CursorType::Wait, StyleCursor::Wait),
718
            (CursorType::Help, StyleCursor::Help),
719
            (CursorType::Progress, StyleCursor::Progress),
720
        ];
721
        for (ct, sc) in shared {
722
            assert_eq!(
723
                translate_cursor_type(ct),
724
                translate_cursor(sc),
725
                "{ct:?} / {sc:?} disagree"
726
            );
727
        }
728
    }
729

            
730
    // ==================================================================
731
    // CursorTypeHitTest::new — degenerate inputs
732
    // ==================================================================
733

            
734
    #[test]
735
    fn empty_hit_test_against_an_empty_window_yields_the_default_cursor() {
736
        let lw = LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
737
        let result = CursorTypeHitTest::new(&FullHitTest::empty(None), &lw);
738
        assert_eq!(result, CursorTypeHitTest::default());
739
        assert_invariant(&result);
740
    }
741

            
742
    #[test]
743
    fn hits_against_a_dom_without_a_layout_result_are_skipped() {
744
        // A hovered DomId with no layout result (child DOM torn down between
745
        // frames) must be skipped, not indexed into.
746
        let lw = window_with(&[DomId::ROOT_ID]);
747
        let hit = make_hit_test(&[
748
            (dom(1), &[(POINTER_DIV, 0)], &[]),
749
            (dom(usize::MAX), &[(BUTTON, 0)], &[(PLAIN_DIV, CursorType::Text, 0)]),
750
        ]);
751
        let result = CursorTypeHitTest::new(&hit, &lw);
752
        assert_eq!(result.cursor_node, None);
753
        assert_eq!(result.cursor_icon, MouseCursorType::Default);
754
        assert_invariant(&result);
755
    }
756

            
757
    #[test]
758
    fn stale_node_ids_past_the_end_of_the_dom_are_skipped_instead_of_panicking() {
759
        // Regression: a VirtualView child rebuilt with fewer nodes leaves the
760
        // hover state holding last frame's (larger) NodeIds. Blind indexing
761
        // panicked with "len is 25 but the index is 27".
762
        let stale = [
763
            (NODE_COUNT, 0u32),
764
            (NODE_COUNT + 1, 1),
765
            (9_999, 2),
766
            (usize::MAX, 3),
767
        ];
768
        let result = resolve(&stale, &[]);
769
        assert_eq!(result.cursor_node, None);
770
        assert_eq!(result.cursor_icon, MouseCursorType::Default);
771
        assert_invariant(&result);
772
    }
773

            
774
    #[test]
775
    fn a_live_node_behind_a_stale_one_still_resolves() {
776
        // The stale id is frontmost (depth 0) but must not consume the search:
777
        // the live pointer div behind it still gets to set the cursor.
778
        let result = resolve(&[(usize::MAX, 0), (POINTER_DIV, 1)], &[]);
779
        assert_eq!(result.cursor_node, root_node(POINTER_DIV));
780
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
781
    }
782

            
783
    #[test]
784
    fn a_stale_node_id_in_a_text_run_hit_is_reported_verbatim() {
785
        // cursor_hit_test_nodes are NOT bounds-checked against the DOM (the
786
        // cursor type is carried in the tag, so no node lookup happens). A stale
787
        // id therefore surfaces as `cursor_node` — harmless for the icon, but
788
        // callers must not assume `cursor_node` indexes a live node.
789
        let result = resolve(&[], &[(usize::MAX, CursorType::Text, 0)]);
790
        assert_eq!(result.cursor_icon, MouseCursorType::Text);
791
        assert_eq!(result.cursor_node, root_node(usize::MAX));
792
    }
793

            
794
    #[test]
795
    fn a_thousand_hits_with_extreme_depths_do_not_panic() {
796
        let mut regular = Vec::new();
797
        let mut cursors = Vec::new();
798
        for i in 0..1000usize {
799
            // Mostly stale ids, alternating extreme depths.
800
            let depth = match i % 4 {
801
                0 => 0,
802
                1 => u32::MAX,
803
                2 => u32::MAX - 1,
804
                _ => i as u32,
805
            };
806
            regular.push((i, depth));
807
            cursors.push((i, CursorType::from_u8((i % 256) as u8), depth));
808
        }
809
        let result = resolve(&regular, &cursors);
810
        assert_invariant(&result);
811
        // Whatever wins the depth race, the resolution must be a decision and
812
        // not a crash — and it must be stable across runs.
813
        assert_eq!(result, resolve(&regular, &cursors));
814
    }
815

            
816
    // ==================================================================
817
    // CursorTypeHitTest::new — depth resolution
818
    // ==================================================================
819

            
820
    #[test]
821
    fn the_frontmost_node_wins_regardless_of_iteration_order() {
822
        // Lower NodeId iterated first, but the deeper one must lose either way.
823
        let front_is_last = resolve(&[(POINTER_DIV, 5), (CROSSHAIR_DIV, 0)], &[]);
824
        assert_eq!(front_is_last.cursor_node, root_node(CROSSHAIR_DIV));
825
        assert_eq!(front_is_last.cursor_icon, MouseCursorType::Crosshair);
826

            
827
        let front_is_first = resolve(&[(POINTER_DIV, 0), (CROSSHAIR_DIV, 5)], &[]);
828
        assert_eq!(front_is_first.cursor_node, root_node(POINTER_DIV));
829
        assert_eq!(front_is_first.cursor_icon, MouseCursorType::Hand);
830
    }
831

            
832
    #[test]
833
    fn on_a_depth_tie_the_first_iterated_node_keeps_the_cursor() {
834
        // The guard is `node_depth >= best_depth`, so a tie never replaces the
835
        // incumbent: with equal depths the lowest NodeId (BTreeMap order) wins.
836
        let result = resolve(&[(POINTER_DIV, 3), (CROSSHAIR_DIV, 3)], &[]);
837
        assert_eq!(result.cursor_node, root_node(POINTER_DIV));
838
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
839
    }
840

            
841
    #[test]
842
    fn a_hit_at_depth_u32_max_can_never_be_selected() {
843
        // BOUNDARY: best_depth is seeded with u32::MAX and the guard is `>=`, so
844
        // depth == u32::MAX is unreachable while u32::MAX - 1 is fine. Depths
845
        // that large are not producible by the real hit-tester, but the seed is
846
        // an in-band sentinel — worth pinning down.
847
        let at_max = resolve(&[(POINTER_DIV, u32::MAX)], &[]);
848
        assert_eq!(at_max.cursor_node, None);
849
        assert_eq!(at_max.cursor_icon, MouseCursorType::Default);
850
        assert_invariant(&at_max);
851

            
852
        let below_max = resolve(&[(POINTER_DIV, u32::MAX - 1)], &[]);
853
        assert_eq!(below_max.cursor_node, root_node(POINTER_DIV));
854
        assert_eq!(below_max.cursor_icon, MouseCursorType::Hand);
855
    }
856

            
857
    #[test]
858
    fn a_text_run_cursor_shadows_a_regular_node_at_the_same_depth() {
859
        // cursor_hit_test_nodes are processed first, so at equal depth the text
860
        // run's tag wins and the CSS cursor behind it never applies.
861
        let result = resolve(&[(POINTER_DIV, 2)], &[(PLAIN_DIV, CursorType::Text, 2)]);
862
        assert_eq!(result.cursor_icon, MouseCursorType::Text);
863
        assert_eq!(result.cursor_node, root_node(PLAIN_DIV));
864
    }
865

            
866
    #[test]
867
    fn a_regular_node_strictly_in_front_of_a_text_run_wins() {
868
        let result = resolve(&[(POINTER_DIV, 1)], &[(PLAIN_DIV, CursorType::Text, 2)]);
869
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
870
        assert_eq!(result.cursor_node, root_node(POINTER_DIV));
871
    }
872

            
873
    #[test]
874
    fn a_default_text_run_cursor_does_not_shadow_the_container_behind_it() {
875
        // A frontmost text run tagged `CursorType::Default` must neither set the
876
        // icon nor lower best_depth — otherwise the button behind it (depth 5)
877
        // would lose its cursor:pointer.
878
        let result = resolve(&[(POINTER_DIV, 5)], &[(PLAIN_DIV, CursorType::Default, 0)]);
879
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
880
        assert_eq!(result.cursor_node, root_node(POINTER_DIV));
881
    }
882

            
883
    // ==================================================================
884
    // CursorTypeHitTest::new — per-node cursor sources
885
    // ==================================================================
886

            
887
    #[test]
888
    fn a_node_without_any_cursor_property_leaves_the_cursor_unset() {
889
        let result = resolve(&[(BODY, 1), (PLAIN_DIV, 0)], &[]);
890
        assert_eq!(result.cursor_node, None);
891
        assert_eq!(result.cursor_icon, MouseCursorType::Default);
892
        assert_invariant(&result);
893
    }
894

            
895
    #[test]
896
    fn a_contenteditable_node_gets_the_ibeam_without_any_css() {
897
        let result = resolve(&[(EDITABLE_DIV, 0)], &[]);
898
        assert_eq!(result.cursor_node, root_node(EDITABLE_DIV));
899
        assert_eq!(result.cursor_icon, MouseCursorType::Text);
900
    }
901

            
902
    #[test]
903
    fn a_textarea_gets_the_ibeam() {
904
        let result = resolve(&[(TEXTAREA, 0)], &[]);
905
        assert_eq!(result.cursor_node, root_node(TEXTAREA));
906
        assert_eq!(result.cursor_icon, MouseCursorType::Text);
907
    }
908

            
909
    #[test]
910
    fn a_button_gets_the_hand_from_the_ua_stylesheet() {
911
        let result = resolve(&[(BUTTON, 0)], &[]);
912
        assert_eq!(result.cursor_node, root_node(BUTTON));
913
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
914
    }
915

            
916
    #[test]
917
    fn an_explicit_cursor_default_is_recorded_and_shadows_nodes_behind_it() {
918
        // Contradicts the doc comment ("The node that has a NON-DEFAULT cursor
919
        // property"): an explicit `cursor: default` sets cursor_node = Some(..)
920
        // with the Default icon. Behaviourally it is still right — the frontmost
921
        // explicit cursor must win — but consumers cannot rely on
922
        // `cursor_node.is_some()` meaning "non-default cursor".
923
        let alone = resolve(&[(DEFAULT_CURSOR_DIV, 0)], &[]);
924
        assert_eq!(alone.cursor_node, root_node(DEFAULT_CURSOR_DIV));
925
        assert_eq!(alone.cursor_icon, MouseCursorType::Default);
926

            
927
        let over_pointer = resolve(&[(DEFAULT_CURSOR_DIV, 0), (POINTER_DIV, 1)], &[]);
928
        assert_eq!(over_pointer.cursor_node, root_node(DEFAULT_CURSOR_DIV));
929
        assert_eq!(over_pointer.cursor_icon, MouseCursorType::Default);
930
    }
931

            
932
    #[test]
933
    fn an_editable_node_in_front_of_a_pointer_node_still_wins() {
934
        let result = resolve(&[(EDITABLE_DIV, 0), (BUTTON, 1)], &[]);
935
        assert_eq!(result.cursor_node, root_node(EDITABLE_DIV));
936
        assert_eq!(result.cursor_icon, MouseCursorType::Text);
937
    }
938

            
939
    // ==================================================================
940
    // CursorTypeHitTest::new — multiple DOMs
941
    // ==================================================================
942

            
943
    #[test]
944
    fn the_frontmost_dom_wins_not_the_lowest_dom_id() {
945
        let lw = window_with(&[DomId::ROOT_ID, dom(1)]);
946

            
947
        // Frontmost hit lives in the *second* DOM.
948
        let hit = make_hit_test(&[
949
            (DomId::ROOT_ID, &[(POINTER_DIV, 4)], &[]),
950
            (dom(1), &[(CROSSHAIR_DIV, 0)], &[]),
951
        ]);
952
        let result = CursorTypeHitTest::new(&hit, &lw);
953
        assert_eq!(result.cursor_node, Some((dom(1), NodeId::new(CROSSHAIR_DIV))));
954
        assert_eq!(result.cursor_icon, MouseCursorType::Crosshair);
955

            
956
        // ... and in the *first* DOM: the later DOM must not clobber it.
957
        let hit = make_hit_test(&[
958
            (DomId::ROOT_ID, &[(CROSSHAIR_DIV, 0)], &[]),
959
            (dom(1), &[(POINTER_DIV, 4)], &[]),
960
        ]);
961
        let result = CursorTypeHitTest::new(&hit, &lw);
962
        assert_eq!(result.cursor_node, root_node(CROSSHAIR_DIV));
963
        assert_eq!(result.cursor_icon, MouseCursorType::Crosshair);
964
    }
965

            
966
    #[test]
967
    fn a_dom_with_a_layout_result_is_used_even_when_a_sibling_dom_is_missing() {
968
        let lw = window_with(&[dom(2)]);
969
        let hit = make_hit_test(&[
970
            (DomId::ROOT_ID, &[(POINTER_DIV, 0)], &[]),
971
            (dom(2), &[(BUTTON, 7)], &[]),
972
        ]);
973
        let result = CursorTypeHitTest::new(&hit, &lw);
974
        assert_eq!(result.cursor_node, Some((dom(2), NodeId::new(BUTTON))));
975
        assert_eq!(result.cursor_icon, MouseCursorType::Hand);
976
    }
977

            
978
    #[test]
979
    fn the_result_is_a_pure_function_of_the_hit_test() {
980
        // Same input twice => same output (no interior mutation of the window).
981
        let lw = window_with(&[DomId::ROOT_ID]);
982
        let hit = make_hit_test(&[(
983
            DomId::ROOT_ID,
984
            &[(POINTER_DIV, 3), (BUTTON, 9)],
985
            &[(TEXTAREA, CursorType::Text, 4)],
986
        )]);
987
        let first = CursorTypeHitTest::new(&hit, &lw);
988
        let second = CursorTypeHitTest::new(&hit, &lw);
989
        assert_eq!(first, second);
990
        assert_eq!(first.cursor_icon, MouseCursorType::Hand);
991
    }
992
}