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

            
5
use alloc::collections::BTreeMap;
6
use core::{
7
    fmt,
8
    sync::atomic::{AtomicU32, Ordering as AtomicOrdering},
9
};
10

            
11
use crate::{
12
    dom::{DomId, DomNodeHash, DomNodeId, OptionDomNodeId, ScrollTagId, ScrollbarOrientation, TagId},
13
    geom::{LogicalPosition, LogicalRect, LogicalSize},
14
    id::NodeId,
15
    resources::IdNamespace,
16
    window::MouseCursorType,
17
    OrderedMap,
18
};
19

            
20
/// Result of a hit test against a single DOM, containing all nodes hit
21
/// by the cursor along with scroll, scrollbar, and cursor-type information.
22
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
23
pub struct HitTest {
24
    pub regular_hit_test_nodes: BTreeMap<NodeId, HitTestItem>,
25
    pub scroll_hit_test_nodes: BTreeMap<NodeId, ScrollHitTestItem>,
26
    /// Hit test results for scrollbar components.
27
    pub scrollbar_hit_test_nodes: BTreeMap<ScrollbarHitId, ScrollbarHitTestItem>,
28
    /// Hit test results for cursor areas (text runs with cursor property).
29
    /// Maps `NodeId` to (`CursorType`, `hit_depth`) - the cursor type and z-depth of the hit.
30
    pub cursor_hit_test_nodes: BTreeMap<NodeId, CursorHitTestItem>,
31
}
32

            
33
/// Hit test item for cursor areas (determines which cursor icon to show).
34
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
35
#[repr(C)]
36
pub struct CursorHitTestItem {
37
    pub cursor_type: CursorType,
38
    pub hit_depth: u32,
39
    pub point_in_viewport: LogicalPosition,
40
}
41

            
42
impl HitTest {
43
61808
    #[must_use] pub const fn empty() -> Self {
44
61808
        Self {
45
61808
            regular_hit_test_nodes: BTreeMap::new(),
46
61808
            scroll_hit_test_nodes: BTreeMap::new(),
47
61808
            scrollbar_hit_test_nodes: BTreeMap::new(),
48
61808
            cursor_hit_test_nodes: BTreeMap::new(),
49
61808
        }
50
61808
    }
51
5
    #[must_use] pub fn is_empty(&self) -> bool {
52
5
        self.regular_hit_test_nodes.is_empty()
53
4
            && self.scroll_hit_test_nodes.is_empty()
54
3
            && self.scrollbar_hit_test_nodes.is_empty()
55
2
            && self.cursor_hit_test_nodes.is_empty()
56
5
    }
57
}
58

            
59
/// Unique identifier for a specific component of a scrollbar.
60
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61
#[repr(C, u8)]
62
pub enum ScrollbarHitId {
63
    VerticalTrack(DomId, NodeId),
64
    VerticalThumb(DomId, NodeId),
65
    HorizontalTrack(DomId, NodeId),
66
    HorizontalThumb(DomId, NodeId),
67
}
68

            
69
/// Hit test item specifically for scrollbar components.
70
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
71
#[repr(C)]
72
pub struct ScrollbarHitTestItem {
73
    pub point_in_viewport: LogicalPosition,
74
    pub point_relative_to_item: LogicalPosition,
75
    pub orientation: ScrollbarOrientation,
76
}
77

            
78
/// Scroll frame identifier combining a unique `u64` tag with its owning `PipelineId`.
79
#[derive(Copy, Clone, Eq, Hash, PartialEq, Ord, PartialOrd)]
80
#[repr(C)]
81
pub struct ExternalScrollId(pub u64, pub PipelineId);
82

            
83
impl ::core::fmt::Display for ExternalScrollId {
84
4
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85
4
        write!(f, "ExternalScrollId({})", self.0)
86
4
    }
87
}
88

            
89
impl ::core::fmt::Debug for ExternalScrollId {
90
2
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91
2
        write!(f, "{self}")
92
2
    }
93
}
94

            
95
/// A node whose content overflows its parent, requiring scroll handling.
96
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
97
pub struct OverflowingScrollNode {
98
    pub parent_rect: LogicalRect,
99
    pub child_rect: LogicalRect,
100
    pub virtual_child_rect: LogicalRect,
101
    pub parent_external_scroll_id: ExternalScrollId,
102
    pub parent_dom_hash: DomNodeHash,
103
    pub scroll_tag_id: ScrollTagId,
104
}
105

            
106
impl Default for OverflowingScrollNode {
107
22
    fn default() -> Self {
108
        use crate::dom::TagId;
109
22
        Self {
110
22
            parent_rect: LogicalRect::zero(),
111
22
            child_rect: LogicalRect::zero(),
112
22
            virtual_child_rect: LogicalRect::zero(),
113
22
            parent_external_scroll_id: ExternalScrollId(0, PipelineId::DUMMY),
114
22
            parent_dom_hash: DomNodeHash { inner: 0 },
115
22
            scroll_tag_id: ScrollTagId {
116
22
                inner: TagId { inner: 0 },
117
22
            },
118
22
        }
119
22
    }
120
}
121

            
122
/// Extra source identifier within a pipeline, allowing multiple independent
123
/// subsystems to generate `PipelineId` values without collision.
124
///
125
/// All pipelines still share the same `IdNamespace` and `DocumentId`.
126
pub type PipelineSourceId = u32;
127

            
128
/// Information about a scroll frame, given to the user by the framework.
129
///
130
/// The two rects are NOT in the same coordinate space — never subtract one
131
/// origin from the other. That silent ambiguity put the scrollbar thumb
132
/// partway down the track for every container not at the window origin.
133
/// See `ScrollManager::get_scroll_states_for_dom` for the producer.
134
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd)]
135
pub struct ScrollPosition {
136
    /// The scroll container's border box in ABSOLUTE window coordinates.
137
    /// `size` is the scrollport ("how big is the parent container", so
138
    /// "scroll to left edge" can be implemented); `origin` is where that
139
    /// container sits on screen and is only meaningful to scroll-into-view.
140
    pub parent_rect: LogicalRect,
141
    /// `size` = the scrollable content ("the union of all children", or the
142
    /// `VirtualView` virtual size when one was reported).
143
    /// `origin` = the SCROLL OFFSET ITSELF — distance already scrolled from
144
    /// the scroll origin, normally clamped to `[0, content − container]`.
145
    /// It is NOT an absolute position and NOT relative to
146
    /// `parent_rect.origin`; content paints at `position − origin`.
147
    pub children_rect: LogicalRect,
148
}
149

            
150
/// Identifies a document within a namespace, used for multi-document rendering.
151
#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
152
pub struct DocumentId {
153
    pub namespace_id: IdNamespace,
154
    pub id: u32,
155
}
156

            
157
impl ::core::fmt::Display for DocumentId {
158
5
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159
5
        write!(
160
5
            f,
161
5
            "DocumentId {{ ns: {}, id: {} }}",
162
            self.namespace_id, self.id
163
        )
164
5
    }
165
}
166

            
167
impl ::core::fmt::Debug for DocumentId {
168
2
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169
2
        write!(f, "{self}")
170
2
    }
171
}
172

            
173
/// Identifies a rendering pipeline by source and sequence number.
174
#[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
175
pub struct PipelineId(pub PipelineSourceId, pub u32);
176

            
177
impl ::core::fmt::Display for PipelineId {
178
3
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179
3
        write!(f, "PipelineId({}, {})", self.0, self.1)
180
3
    }
181
}
182

            
183
impl ::core::fmt::Debug for PipelineId {
184
1
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185
1
        write!(f, "{self}")
186
1
    }
187
}
188

            
189
static LAST_PIPELINE_ID: AtomicU32 = AtomicU32::new(0);
190

            
191
impl Default for PipelineId {
192
1
    fn default() -> Self {
193
1
        Self::new()
194
1
    }
195
}
196

            
197
impl PipelineId {
198
    pub const DUMMY: Self = Self(0, 0);
199

            
200
3
    pub fn new() -> Self {
201
3
        Self(
202
3
            LAST_PIPELINE_ID.fetch_add(1, AtomicOrdering::SeqCst),
203
3
            0,
204
3
        )
205
3
    }
206
}
207

            
208
/// A single hit-test result for a regular (non-scroll) DOM node.
209
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
210
pub struct HitTestItem {
211
    /// The hit point in the coordinate space of the "viewport" of the display item.
212
    /// The viewport is the scroll node formed by the root reference frame of the display item's
213
    /// pipeline.
214
    pub point_in_viewport: LogicalPosition,
215
    /// The coordinates of the original hit test point relative to the origin of this item.
216
    /// This is useful for calculating things like text offsets in the client.
217
    pub point_relative_to_item: LogicalPosition,
218
    /// Necessary to easily get the nearest `VirtualView` node
219
    pub is_focusable: bool,
220
    /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
221
    pub is_virtual_view_hit: Option<(DomId, LogicalPosition)>,
222
    /// Z-order depth from `WebRender` hit test (0 = frontmost/topmost in z-order).
223
    /// Lower values are closer to the user. This preserves the ordering from
224
    /// `WebRender`'s hit test results which returns items front-to-back.
225
    pub hit_depth: u32,
226
}
227

            
228
/// A hit-test result for a scrollable DOM node.
229
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
230
pub struct ScrollHitTestItem {
231
    /// The hit point in the coordinate space of the "viewport" of the display item.
232
    /// The viewport is the scroll node formed by the root reference frame of the display item's
233
    /// pipeline.
234
    pub point_in_viewport: LogicalPosition,
235
    /// The coordinates of the original hit test point relative to the origin of this item.
236
    /// This is useful for calculating things like text offsets in the client.
237
    pub point_relative_to_item: LogicalPosition,
238
    /// If this hit is a `VirtualView` node, stores the `VirtualViews` `DomId` + the origin of the `VirtualView`
239
    pub scroll_node: OverflowingScrollNode,
240
}
241

            
242
/// Map of active scroll states, keyed by their external scroll ID.
243
#[derive(Debug, Default)]
244
pub struct ScrollStates(pub OrderedMap<ExternalScrollId, ScrollState>);
245

            
246
impl ScrollStates {
247
6
    #[must_use] pub fn new() -> Self {
248
6
        Self::default()
249
6
    }
250

            
251
11
    #[must_use] pub fn get_scroll_position(&self, scroll_id: &ExternalScrollId) -> Option<LogicalPosition> {
252
11
        self.0.get(scroll_id).map(ScrollState::get)
253
11
    }
254

            
255
    /// Set the scroll amount - does not update the `entry.used_this_frame`,
256
    /// since that is only relevant when we are actually querying the renderer.
257
3
    pub fn set_scroll_position(
258
3
        &mut self,
259
3
        node: &OverflowingScrollNode,
260
3
        scroll_position: LogicalPosition,
261
3
    ) {
262
3
        let max_scroll = max_scroll_rect(node);
263
3
        self.0
264
3
            .entry(node.parent_external_scroll_id)
265
3
            .or_default()
266
3
            .set(scroll_position.x, scroll_position.y, &max_scroll);
267
3
    }
268

            
269
    /// Updating (add to) the existing scroll amount does not update the
270
    /// `entry.used_this_frame`, since that is only relevant when we are
271
    /// actually querying the renderer.
272
7
    pub fn scroll_node(
273
7
        &mut self,
274
7
        node: &OverflowingScrollNode,
275
7
        scroll_by_x: f32,
276
7
        scroll_by_y: f32,
277
7
    ) {
278
7
        let max_scroll = max_scroll_rect(node);
279
7
        self.0
280
7
            .entry(node.parent_external_scroll_id)
281
7
            .or_default()
282
7
            .add(scroll_by_x, scroll_by_y, &max_scroll);
283
7
    }
284
}
285

            
286
/// Compute the maximum scrollable range for a scroll node.
287
///
288
/// The maximum scroll offset is `content − viewport` (`child_rect − parent_rect`),
289
/// clamped to `>= 0`. Previously the scroll position was clamped to the full
290
/// content size, which let the content scroll entirely out of view. The returned
291
/// rect keeps `child_rect.origin` and stores the max offset in `size`.
292
18
fn max_scroll_rect(node: &OverflowingScrollNode) -> LogicalRect {
293
18
    LogicalRect::new(
294
18
        node.child_rect.origin,
295
18
        LogicalSize::new(
296
18
            (node.child_rect.size.width - node.parent_rect.size.width).max(0.0),
297
18
            (node.child_rect.size.height - node.parent_rect.size.height).max(0.0),
298
        ),
299
    )
300
18
}
301

            
302
/// Current scroll position for a single scroll frame.
303
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd)]
304
#[repr(C)]
305
pub struct ScrollState {
306
    /// Amount in pixel that the current node is scrolled
307
    pub scroll_position: LogicalPosition,
308
}
309

            
310
impl_option!(
311
    ScrollState,
312
    OptionScrollState,
313
    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd]
314
);
315

            
316
impl ScrollState {
317
    /// Return the current position of the scroll state
318
44
    #[must_use] pub const fn get(&self) -> LogicalPosition {
319
44
        self.scroll_position
320
44
    }
321

            
322
    /// Add a scroll X / Y onto the existing scroll state.
323
    ///
324
    /// `max_scroll_rect` is the *scroll range* rect: its size is the maximum
325
    /// scrollable offset (`content − viewport`, clamped to `>= 0`), NOT the full
326
    /// content size. See [`ScrollStates::scroll_node`]. Clamping via `.max(0.0)`
327
    /// first also collapses any NaN input to `0.0` (`f32::max` returns the
328
    /// non-NaN operand), so a NaN delta can never poison the scroll position.
329
18
    pub fn add(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
330
18
        self.scroll_position.x = (self.scroll_position.x + x)
331
18
            .max(0.0)
332
18
            .min(max_scroll_rect.size.width.max(0.0));
333
18
        self.scroll_position.y = (self.scroll_position.y + y)
334
18
            .max(0.0)
335
18
            .min(max_scroll_rect.size.height.max(0.0));
336
18
    }
337

            
338
    /// Set the scroll state to a new position.
339
    ///
340
    /// `max_scroll_rect` is the *scroll range* rect (see [`ScrollState::add`]).
341
13
    pub const fn set(&mut self, x: f32, y: f32, max_scroll_rect: &LogicalRect) {
342
13
        self.scroll_position.x = x.max(0.0).min(max_scroll_rect.size.width.max(0.0));
343
13
        self.scroll_position.y = y.max(0.0).min(max_scroll_rect.size.height.max(0.0));
344
13
    }
345
}
346

            
347
impl Default for ScrollState {
348
17
    fn default() -> Self {
349
17
        Self {
350
17
            scroll_position: LogicalPosition::zero(),
351
17
        }
352
17
    }
353
}
354

            
355
/// Complete hit-test result across all DOMs, including the currently focused node.
356
#[derive(Debug, Clone, PartialEq, Eq)]
357
pub struct FullHitTest {
358
    pub hovered_nodes: BTreeMap<DomId, HitTest>,
359
    pub focused_node: OptionDomNodeId,
360
}
361

            
362
impl FullHitTest {
363
    /// Create an empty hit-test result
364
12379
    #[must_use] pub fn empty(focused_node: Option<DomNodeId>) -> Self {
365
12379
        Self {
366
12379
            hovered_nodes: BTreeMap::new(),
367
12379
            focused_node: focused_node.into(),
368
12379
        }
369
12379
    }
370

            
371
    /// Returns `true` if no nodes were hovered (ignores `focused_node`).
372
5
    #[must_use] pub fn is_empty(&self) -> bool {
373
5
        self.hovered_nodes.is_empty()
374
5
    }
375
}
376

            
377
/// Result of determining which mouse cursor icon to display based on hit-test results.
378
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379
pub struct CursorTypeHitTest {
380
    /// closest-node is used for determining the cursor: property
381
    /// The node is guaranteed to have a non-default cursor: property,
382
    /// so that the cursor icon can be set accordingly
383
    pub cursor_node: Option<(DomId, NodeId)>,
384
    /// Mouse cursor type to set (if `cursor_node` is None, this is set to
385
    /// `MouseCursorType::Default`)
386
    pub cursor_icon: MouseCursorType,
387
}
388

            
389
// ============================================================================
390
// Type-safe hit-test tag system (merged from the former `hit_test_tag` module).
391
//
392
// Encodes WebRender's ItemTag = (u64, u16): the tag *type* lives in the upper
393
// byte of tag.1 (DOM node / scrollbar / selection / cursor / scroll-container),
394
// keeping tag types free of bit-level conflicts. See the TAG_TYPE_* constants.
395
// ============================================================================
396
// ============================================================================
397
// Tag Type Markers (stored in upper byte of ItemTag.1)
398
// ============================================================================
399

            
400
/// Marker for DOM node tags (regular UI elements with callbacks, focus, etc.)
401
pub const TAG_TYPE_DOM_NODE: u16 = 0x0100;
402

            
403
/// Marker for scrollbar component tags
404
pub const TAG_TYPE_SCROLLBAR: u16 = 0x0200;
405

            
406
/// Marker for text selection hit-test areas (determines text selection regions)
407
///
408
/// These are pushed for text runs to enable text selection without affecting
409
/// other hit-test logic. Selection may trigger re-rendering.
410
///
411
/// NOTE: Text selection hit-testing currently uses `TAG_TYPE_CURSOR` (0x0400).
412
/// This constant is used by the `HitTestTag::Selection` variant for encoding
413
/// selection-specific tags (e.g., text run selection areas).
414
pub const TAG_TYPE_SELECTION: u16 = 0x0300;
415

            
416
/// Marker for cursor hit-test areas (determines which cursor icon to show)
417
///
418
/// These are separate from DOM node tags to allow efficient cursor resolution
419
/// without iterating over all DOM nodes. Cursor changes never require re-rendering.
420
pub const TAG_TYPE_CURSOR: u16 = 0x0400;
421

            
422
/// Marker for scroll container hit-test areas (for trackpad/wheel scrolling)
423
///
424
/// These identify scrollable containers even when no DOM node callbacks are registered.
425
/// Scroll containers push this tag so the scroll manager can find them during wheel events.
426
pub const TAG_TYPE_SCROLL_CONTAINER: u16 = 0x0500;
427

            
428

            
429
// ============================================================================
430
// Scrollbar Component Types (stored in lower byte of ItemTag.1 for scrollbar tags)
431
// ============================================================================
432

            
433
/// Scrollbar component type identifier.
434
///
435
/// Each scrollable container can have up to 2 scrollbars (vertical + horizontal),
436
/// and each scrollbar has 2 main hit regions (track + thumb).
437
///
438
/// Future extensions could add:
439
/// - `UpButton`, `DownButton`, `LeftButton`, `RightButton` for scroll arrows
440
/// - `PageUp`, `PageDown` for page-scroll regions
441
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
442
#[repr(u8)]
443
pub enum ScrollbarComponent {
444
    /// The vertical scrollbar track (background area)
445
    VerticalTrack = 0,
446
    /// The vertical scrollbar thumb (draggable handle)
447
    VerticalThumb = 1,
448
    /// The horizontal scrollbar track (background area)
449
    HorizontalTrack = 2,
450
    /// The horizontal scrollbar thumb (draggable handle)
451
    HorizontalThumb = 3,
452
    // Future: scroll arrow buttons
453
    // VerticalUpButton = 4,
454
    // VerticalDownButton = 5,
455
    // HorizontalLeftButton = 6,
456
    // HorizontalRightButton = 7,
457
}
458

            
459
impl ScrollbarComponent {
460
    /// Convert from raw u8 value
461
538
    #[must_use] pub const fn from_u8(value: u8) -> Option<Self> {
462
538
        match value {
463
9
            0 => Some(Self::VerticalTrack),
464
8
            1 => Some(Self::VerticalThumb),
465
6
            2 => Some(Self::HorizontalTrack),
466
7
            3 => Some(Self::HorizontalThumb),
467
508
            _ => None,
468
        }
469
538
    }
470

            
471
}
472

            
473
// ============================================================================
474
// WebRender Hit-Test Tag (unified type-safe representation)
475
// ============================================================================
476

            
477
/// Unified, type-safe representation of a `WebRender` hit-test tag.
478
///
479
/// This enum represents all possible types of hit-test targets. Each variant
480
/// can be encoded to and decoded from `WebRender`'s `(u64, u16)` `ItemTag` format.
481
///
482
/// ## Namespace Separation
483
///
484
/// Different tag types are kept in separate namespaces to:
485
/// - Enable efficient hit-test queries (only iterate over relevant tags)
486
/// - Get automatic depth sorting from `WebRender` per namespace
487
/// - Prevent accidental collisions between different hit-test purposes
488
///
489
/// | Namespace | Purpose                              |
490
/// |-----------|--------------------------------------|
491
/// | 0x0100    | DOM nodes (callbacks, focus, hover)  |
492
/// | 0x0200    | Scrollbar components                 |
493
/// | 0x0300    | Selection areas (text selection)     |
494
/// | 0x0400    | Cursor areas (cursor icon display)     |
495
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
496
pub enum HitTestTag {
497
    /// A regular DOM node (button, div, text container, etc.)
498
    ///
499
    /// These are nodes that have callbacks, are focusable, or have hover styles.
500
    /// The `TagId` is a sequential counter assigned during DOM styling.
501
    DomNode {
502
        /// The unique tag ID assigned to this DOM node
503
        tag_id: TagId,
504
    },
505

            
506
    /// A scrollbar component (track or thumb)
507
    ///
508
    /// Each scrollable container can have up to 2 scrollbars.
509
    /// The scrollbar is identified by the `DomId` and `NodeId` of the scrollable container.
510
    Scrollbar {
511
        /// The DOM that contains the scrollable container
512
        dom_id: DomId,
513
        /// The `NodeId` of the scrollable container (not the scrollbar itself)
514
        node_id: NodeId,
515
        /// Which component of the scrollbar was hit
516
        component: ScrollbarComponent,
517
    },
518

            
519
    /// A cursor hit-test area (determines which cursor icon to display)
520
    ///
521
    /// These are pushed separately from DOM nodes to allow efficient cursor
522
    /// resolution. The cursor type is encoded in the lower byte of tag.1.
523
    Cursor {
524
        /// The DOM node this cursor area belongs to
525
        dom_id: DomId,
526
        /// The `NodeId` of the element with the cursor property
527
        node_id: NodeId,
528
        /// The cursor type to display when hovering over this area
529
        cursor_type: CursorType,
530
    },
531

            
532
    /// A text selection hit-test area
533
    ///
534
    /// These are pushed for text runs to enable text selection.
535
    /// Separate from DOM nodes to prevent interference with other hit-testing.
536
    Selection {
537
        /// The DOM containing the text
538
        dom_id: DomId,
539
        /// The `NodeId` of the text container (not the Text node itself)
540
        container_node_id: NodeId,
541
        /// The index of the text run within the container (for multi-line text)
542
        text_run_index: u16,
543
    },
544
}
545

            
546
/// Cursor type encoded in cursor hit-test tags.
547
/// Stored in the lower byte of the ItemTag.1 field.
548
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
549
#[repr(u8)]
550
pub enum CursorType {
551
    #[default]
552
    Default = 0,
553
    Pointer = 1,
554
    Text = 2,
555
    Crosshair = 3,
556
    Move = 4,
557
    NotAllowed = 5,
558
    Grab = 6,
559
    Grabbing = 7,
560
    EResize = 8,
561
    WResize = 9,
562
    NResize = 10,
563
    SResize = 11,
564
    EwResize = 12,
565
    NsResize = 13,
566
    NeswResize = 14,
567
    NwseResize = 15,
568
    ColResize = 16,
569
    RowResize = 17,
570
    Wait = 18,
571
    Help = 19,
572
    Progress = 20,
573
    // Add more as needed, up to 255
574
}
575

            
576
impl CursorType {
577
    /// Convert from raw u8 value
578
    // Explicit u8 -> variant table documenting every discriminant; `0 => Default`
579
    // intentionally mirrors the `_ => Default` fallback (the `#[default]` is 0).
580
    #[allow(clippy::match_same_arms)]
581
14356
    #[must_use] pub const fn from_u8(value: u8) -> Self {
582
14356
        match value {
583
71
            0 => Self::Default,
584
70
            1 => Self::Pointer,
585
69
            2 => Self::Text,
586
70
            3 => Self::Crosshair,
587
70
            4 => Self::Move,
588
69
            5 => Self::NotAllowed,
589
69
            6 => Self::Grab,
590
69
            7 => Self::Grabbing,
591
69
            8 => Self::EResize,
592
69
            9 => Self::WResize,
593
69
            10 => Self::NResize,
594
69
            11 => Self::SResize,
595
69
            12 => Self::EwResize,
596
69
            13 => Self::NsResize,
597
69
            14 => Self::NeswResize,
598
69
            15 => Self::NwseResize,
599
69
            16 => Self::ColResize,
600
69
            17 => Self::RowResize,
601
69
            18 => Self::Wait,
602
69
            19 => Self::Help,
603
70
            20 => Self::Progress,
604
12901
            _ => Self::Default,
605
        }
606
14356
    }
607
}
608

            
609
impl HitTestTag {
610
    /// Encode this tag to `WebRender`'s `ItemTag` format.
611
    ///
612
    /// Returns `(u64, u16)` suitable for passing to `WebRender`'s `push_hit_test`.
613
56
    #[must_use] pub fn to_item_tag(&self) -> (u64, u16) {
614
56
        match self {
615
8
            Self::DomNode { tag_id } => {
616
                // tag.0 = TagId.inner (the sequential counter)
617
                // tag.1 = TAG_TYPE_DOM_NODE marker
618
8
                (tag_id.inner, TAG_TYPE_DOM_NODE)
619
            }
620
            Self::Scrollbar {
621
19
                dom_id,
622
19
                node_id,
623
19
                component,
624
            } => {
625
                // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
626
19
                let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
627
                // tag.1 = TAG_TYPE_SCROLLBAR | component type in lower byte
628
19
                let tag_type = TAG_TYPE_SCROLLBAR | (*component as u16);
629
19
                (tag_value, tag_type)
630
            }
631
            Self::Cursor {
632
22
                dom_id,
633
22
                node_id,
634
22
                cursor_type,
635
            } => {
636
                // tag.0 = DomId (upper 32 bits) | NodeId (lower 32 bits)
637
22
                let tag_value = ((dom_id.inner as u64) << 32) | (node_id.index() as u64);
638
                // tag.1 = TAG_TYPE_CURSOR | cursor type in lower byte
639
22
                let tag_type = TAG_TYPE_CURSOR | (*cursor_type as u16);
640
22
                (tag_value, tag_type)
641
            }
642
            Self::Selection {
643
7
                dom_id,
644
7
                container_node_id,
645
7
                text_run_index,
646
            } => {
647
                // tag.0 = DomId (upper 16 bits) | NodeId (middle 32 bits) | text_run_index (lower 16 bits)
648
                // AUDIT: mask each field to its bit width so an out-of-range DomId /
649
                // NodeId can never bleed into an adjacent field (silent cross-field
650
                // corruption). Masking clamps consistently in debug and release —
651
                // a >16-bit DomId is absurd but must degrade gracefully, not panic.
652
7
                let dom_bits = (dom_id.inner as u64) & 0xFFFF;
653
7
                let node_bits = (container_node_id.index() as u64) & 0xFFFF_FFFF;
654
7
                let tag_value = (dom_bits << 48)
655
7
                    | (node_bits << 16)
656
7
                    | u64::from(*text_run_index);
657
7
                (tag_value, TAG_TYPE_SELECTION)
658
            }
659
        }
660
56
    }
661

            
662
    /// Decode a `WebRender` `ItemTag` back to a typed `HitTestTag`.
663
    ///
664
    /// Returns `None` if the tag format is invalid or unrecognized.
665
2364
    #[must_use] pub fn from_item_tag(tag: (u64, u16)) -> Option<Self> {
666
2364
        let (tag_value, tag_type) = tag;
667

            
668
        // Extract tag type from upper byte
669
2364
        let type_marker = tag_type & 0xFF00;
670

            
671
2364
        match type_marker {
672
            TAG_TYPE_DOM_NODE => {
673
                // DOM node tag: tag.0 is the TagId
674
19
                Some(Self::DomNode {
675
19
                    tag_id: TagId { inner: tag_value },
676
19
                })
677
            }
678
            TAG_TYPE_SCROLLBAR => {
679
                // Scrollbar tag: decode DomId, NodeId, and component
680
278
                let dom_id = DomId {
681
278
                    inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
682
278
                };
683
278
                let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
684
278
                let component_value = (tag_type & 0x00FF) as u8;
685
278
                let component = ScrollbarComponent::from_u8(component_value)?;
686

            
687
22
                Some(Self::Scrollbar {
688
22
                    dom_id,
689
22
                    node_id,
690
22
                    component,
691
22
                })
692
            }
693
            TAG_TYPE_CURSOR => {
694
                // Cursor tag: decode DomId, NodeId, and cursor type
695
32
                let dom_id = DomId {
696
32
                    inner: ((tag_value >> 32) & 0xFFFF_FFFF) as usize,
697
32
                };
698
32
                let node_id = NodeId::new((tag_value & 0xFFFF_FFFF) as usize);
699
32
                let cursor_value = (tag_type & 0x00FF) as u8;
700
32
                let cursor_type = CursorType::from_u8(cursor_value);
701

            
702
32
                Some(Self::Cursor {
703
32
                    dom_id,
704
32
                    node_id,
705
32
                    cursor_type,
706
32
                })
707
            }
708
            TAG_TYPE_SELECTION => {
709
                // Selection tag: decode DomId, NodeId, and text run index
710
12
                let dom_id = DomId {
711
12
                    inner: ((tag_value >> 48) & 0xFFFF) as usize,
712
12
                };
713
12
                let container_node_id = NodeId::new(((tag_value >> 16) & 0xFFFF_FFFF) as usize);
714
12
                let text_run_index = (tag_value & 0xFFFF) as u16;
715

            
716
12
                Some(Self::Selection {
717
12
                    dom_id,
718
12
                    container_node_id,
719
12
                    text_run_index,
720
12
                })
721
            }
722
            _ => {
723
                // Unknown tag type - could be a legacy tag or corruption
724
                // For backwards compatibility, treat tags with tag_type == 0
725
                // as legacy DOM node tags (old format before type markers)
726
2023
                if tag_type == 0 {
727
3
                    Some(Self::DomNode {
728
3
                        tag_id: TagId { inner: tag_value },
729
3
                    })
730
                } else {
731
2020
                    None
732
                }
733
            }
734
        }
735
2364
    }
736

            
737
    /// Check if this is a DOM node tag
738
15
    #[must_use] pub const fn is_dom_node(&self) -> bool {
739
15
        matches!(self, Self::DomNode { .. })
740
15
    }
741

            
742
    /// Check if this is a scrollbar tag
743
10
    #[must_use] pub const fn is_scrollbar(&self) -> bool {
744
10
        matches!(self, Self::Scrollbar { .. })
745
10
    }
746

            
747
    /// Check if this is a cursor tag
748
9
    #[must_use] pub const fn is_cursor(&self) -> bool {
749
9
        matches!(self, Self::Cursor { .. })
750
9
    }
751

            
752
    /// Check if this is a selection tag
753
9
    #[must_use] pub const fn is_selection(&self) -> bool {
754
9
        matches!(self, Self::Selection { .. })
755
9
    }
756

            
757
    /// Get the `TagId` if this is a DOM node tag
758
20
    #[must_use] pub const fn as_dom_node(&self) -> Option<TagId> {
759
20
        match self {
760
14
            Self::DomNode { tag_id } => Some(*tag_id),
761
6
            _ => None,
762
        }
763
20
    }
764

            
765
    /// Get cursor info if this is a cursor tag
766
12
    #[must_use] pub const fn as_cursor(&self) -> Option<(DomId, NodeId, CursorType)> {
767
12
        match self {
768
            Self::Cursor {
769
6
                dom_id,
770
6
                node_id,
771
6
                cursor_type,
772
6
            } => Some((*dom_id, *node_id, *cursor_type)),
773
6
            _ => None,
774
        }
775
12
    }
776

            
777
    /// Get selection info if this is a selection tag
778
9
    #[must_use] pub const fn as_selection(&self) -> Option<(DomId, NodeId, u16)> {
779
9
        match self {
780
            Self::Selection {
781
3
                dom_id,
782
3
                container_node_id,
783
3
                text_run_index,
784
3
            } => Some((*dom_id, *container_node_id, *text_run_index)),
785
6
            _ => None,
786
        }
787
9
    }
788

            
789
    /// Get scrollbar info if this is a scrollbar tag
790
10
    #[must_use] pub const fn as_scrollbar(&self) -> Option<(DomId, NodeId, ScrollbarComponent)> {
791
10
        match self {
792
            Self::Scrollbar {
793
4
                dom_id,
794
4
                node_id,
795
4
                component,
796
4
            } => Some((*dom_id, *node_id, *component)),
797
6
            _ => None,
798
        }
799
10
    }
800
}
801

            
802
impl fmt::Display for HitTestTag {
803
8
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
804
8
        match self {
805
2
            Self::DomNode { tag_id } => {
806
2
                write!(f, "DomNode(tag:{})", tag_id.inner)
807
            }
808
            Self::Scrollbar {
809
2
                dom_id,
810
2
                node_id,
811
2
                component,
812
            } => {
813
2
                write!(
814
2
                    f,
815
2
                    "Scrollbar(dom:{}, node:{}, {:?})",
816
                    dom_id.inner,
817
2
                    node_id.index(),
818
                    component
819
                )
820
            }
821
            Self::Cursor {
822
2
                dom_id,
823
2
                node_id,
824
2
                cursor_type,
825
            } => {
826
2
                write!(
827
2
                    f,
828
2
                    "Cursor(dom:{}, node:{}, {:?})",
829
                    dom_id.inner,
830
2
                    node_id.index(),
831
                    cursor_type
832
                )
833
            }
834
            Self::Selection {
835
2
                dom_id,
836
2
                container_node_id,
837
2
                text_run_index,
838
            } => {
839
2
                write!(
840
2
                    f,
841
2
                    "Selection(dom:{}, container:{}, run:{})",
842
                    dom_id.inner,
843
2
                    container_node_id.index(),
844
                    text_run_index
845
                )
846
            }
847
        }
848
8
    }
849
}
850

            
851
#[cfg(test)]
852
#[allow(clippy::float_cmp)] // exact-value assertions on computed layout floats
853
mod tests {
854
    use super::*;
855

            
856
    #[test]
857
1
    fn test_dom_node_tag_roundtrip() {
858
1
        let tag = HitTestTag::DomNode {
859
1
            tag_id: TagId { inner: 42 },
860
1
        };
861
1
        let item_tag = tag.to_item_tag();
862
1
        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
863
1
        assert_eq!(tag, decoded);
864
1
    }
865

            
866
    #[test]
867
1
    fn test_scrollbar_tag_roundtrip() {
868
1
        let tag = HitTestTag::Scrollbar {
869
1
            dom_id: DomId { inner: 1 },
870
1
            node_id: NodeId::new(123),
871
1
            component: ScrollbarComponent::VerticalThumb,
872
1
        };
873
1
        let item_tag = tag.to_item_tag();
874
1
        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
875
1
        assert_eq!(tag, decoded);
876
1
    }
877

            
878
    #[test]
879
1
    fn test_dom_node_tag_not_confused_with_scrollbar() {
880
        // A DOM node tag with value 673 should NOT be decoded as a scrollbar
881
1
        let dom_tag = HitTestTag::DomNode {
882
1
            tag_id: TagId { inner: 673 },
883
1
        };
884
1
        let item_tag = dom_tag.to_item_tag();
885

            
886
        // Verify it has the correct type marker
887
1
        assert_eq!(item_tag.1, TAG_TYPE_DOM_NODE);
888

            
889
        // Verify it decodes correctly
890
1
        let decoded = HitTestTag::from_item_tag(item_tag).unwrap();
891
1
        assert!(decoded.is_dom_node());
892
1
        assert!(!decoded.is_scrollbar());
893
1
    }
894

            
895
    #[test]
896
1
    fn test_legacy_tag_compatibility() {
897
        // Old format tags had tag.1 == 0
898
        // They should be treated as DOM node tags for backwards compatibility
899
1
        let legacy_tag = (42u64, 0u16);
900
1
        let decoded = HitTestTag::from_item_tag(legacy_tag).unwrap();
901
1
        assert!(decoded.is_dom_node());
902
1
        assert_eq!(decoded.as_dom_node().unwrap().inner, 42);
903
1
    }
904

            
905
5
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
906
5
        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
907
5
    }
908

            
909
    #[test]
910
1
    fn scroll_state_clamps_to_content_minus_viewport() {
911
        // content 300 tall, viewport 100 tall -> max scroll offset = 200
912
1
        let max = max_scroll_rect(&OverflowingScrollNode {
913
1
            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
914
1
            child_rect: rect(0.0, 0.0, 100.0, 300.0),
915
1
            ..Default::default()
916
1
        });
917
1
        assert_eq!(max.size.height, 200.0);
918

            
919
1
        let mut st = ScrollState::default();
920
1
        st.set(0.0, 999.0, &max);
921
1
        assert_eq!(st.scroll_position.y, 200.0); // not 300 (content would fully scroll away)
922
1
    }
923

            
924
    #[test]
925
1
    fn scroll_state_no_scroll_when_content_fits() {
926
        // content == viewport -> zero scroll range
927
1
        let max = max_scroll_rect(&OverflowingScrollNode {
928
1
            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
929
1
            child_rect: rect(0.0, 0.0, 100.0, 100.0),
930
1
            ..Default::default()
931
1
        });
932
1
        let mut st = ScrollState::default();
933
1
        st.add(50.0, 50.0, &max);
934
1
        assert_eq!(st.scroll_position, LogicalPosition::zero());
935
1
    }
936

            
937
    #[test]
938
1
    fn scroll_state_nan_delta_does_not_poison() {
939
1
        let max = rect(0.0, 0.0, 100.0, 200.0);
940
1
        let mut st = ScrollState::default();
941
1
        st.add(f32::NAN, f32::NAN, &max);
942
1
        assert_eq!(st.scroll_position, LogicalPosition::zero());
943
1
    }
944

            
945
    #[test]
946
1
    fn selection_tag_out_of_range_domid_is_clamped_not_corrupting() {
947
        // A DomId > 0xFFFF must not bleed into the NodeId field. The masked
948
        // encode/decode round-trips within the 16-bit DomId window.
949
1
        let tag = HitTestTag::Selection {
950
1
            dom_id: DomId { inner: 0x1_0007 }, // exceeds 16 bits
951
1
            container_node_id: NodeId::new(5),
952
1
            text_run_index: 9,
953
1
        };
954
1
        let (value, ty) = tag.to_item_tag();
955
1
        assert_eq!(ty, TAG_TYPE_SELECTION);
956
        // NodeId field (bits 16..48) is exactly 5, uncorrupted by the overflow.
957
1
        assert_eq!((value >> 16) & 0xFFFF_FFFF, 5);
958
        // DomId field is the low 16 bits of the input (0x0007).
959
1
        assert_eq!(value >> 48, 0x0007);
960
1
    }
961
}
962

            
963
#[cfg(test)]
964
#[allow(
965
    clippy::float_cmp,
966
    clippy::cast_lossless,
967
    clippy::cast_possible_truncation,
968
    clippy::unreadable_literal
969
)]
970
mod autotest_generated {
971
    use super::*;
972

            
973
    // ------------------------------------------------------------------
974
    // helpers
975
    // ------------------------------------------------------------------
976

            
977
    fn r(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
978
        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
979
    }
980

            
981
    /// An `OverflowingScrollNode` with only the two rects that `max_scroll_rect`
982
    /// actually reads set; everything else stays at its `Default`.
983
    fn scroll_node_of(parent: LogicalRect, child: LogicalRect) -> OverflowingScrollNode {
984
        OverflowingScrollNode {
985
            parent_rect: parent,
986
            child_rect: child,
987
            ..Default::default()
988
        }
989
    }
990

            
991
    fn ext_id(raw: u64) -> ExternalScrollId {
992
        ExternalScrollId(raw, PipelineId::DUMMY)
993
    }
994

            
995
    const ALL_CURSORS: [CursorType; 21] = [
996
        CursorType::Default,
997
        CursorType::Pointer,
998
        CursorType::Text,
999
        CursorType::Crosshair,
        CursorType::Move,
        CursorType::NotAllowed,
        CursorType::Grab,
        CursorType::Grabbing,
        CursorType::EResize,
        CursorType::WResize,
        CursorType::NResize,
        CursorType::SResize,
        CursorType::EwResize,
        CursorType::NsResize,
        CursorType::NeswResize,
        CursorType::NwseResize,
        CursorType::ColResize,
        CursorType::RowResize,
        CursorType::Wait,
        CursorType::Help,
        CursorType::Progress,
    ];
    const ALL_SCROLLBAR_COMPONENTS: [ScrollbarComponent; 4] = [
        ScrollbarComponent::VerticalTrack,
        ScrollbarComponent::VerticalThumb,
        ScrollbarComponent::HorizontalTrack,
        ScrollbarComponent::HorizontalThumb,
    ];
    // ------------------------------------------------------------------
    // HitTest / FullHitTest — constructors + predicates
    // ------------------------------------------------------------------
    #[test]
    fn hit_test_empty_is_neutral_element() {
        let h = HitTest::empty();
        assert!(h.is_empty());
        assert_eq!(h.regular_hit_test_nodes.len(), 0);
        assert_eq!(h.scroll_hit_test_nodes.len(), 0);
        assert_eq!(h.scrollbar_hit_test_nodes.len(), 0);
        assert_eq!(h.cursor_hit_test_nodes.len(), 0);
        // repeated construction is stable / equal
        assert_eq!(HitTest::empty(), HitTest::empty());
    }
    #[test]
    fn hit_test_is_empty_false_if_any_single_map_is_populated() {
        // Each of the four maps must independently flip `is_empty()` to false,
        // otherwise a hit in (say) only the cursor map would be silently dropped.
        let mut a = HitTest::empty();
        a.regular_hit_test_nodes.insert(
            NodeId::ZERO,
            HitTestItem {
                point_in_viewport: LogicalPosition::zero(),
                point_relative_to_item: LogicalPosition::zero(),
                is_focusable: false,
                is_virtual_view_hit: None,
                hit_depth: 0,
            },
        );
        assert!(!a.is_empty());
        let mut b = HitTest::empty();
        b.scroll_hit_test_nodes.insert(
            NodeId::new(usize::MAX),
            ScrollHitTestItem {
                point_in_viewport: LogicalPosition::zero(),
                point_relative_to_item: LogicalPosition::zero(),
                scroll_node: OverflowingScrollNode::default(),
            },
        );
        assert!(!b.is_empty());
        let mut c = HitTest::empty();
        c.scrollbar_hit_test_nodes.insert(
            ScrollbarHitId::VerticalTrack(DomId::ROOT_ID, NodeId::ZERO),
            ScrollbarHitTestItem {
                point_in_viewport: LogicalPosition::zero(),
                point_relative_to_item: LogicalPosition::zero(),
                orientation: ScrollbarOrientation::Vertical,
            },
        );
        assert!(!c.is_empty());
        let mut d = HitTest::empty();
        d.cursor_hit_test_nodes.insert(
            NodeId::ZERO,
            CursorHitTestItem {
                cursor_type: CursorType::Default,
                hit_depth: u32::MAX,
                point_in_viewport: LogicalPosition::new(f32::NAN, f32::INFINITY),
            },
        );
        assert!(!d.is_empty());
    }
    #[test]
    fn full_hit_test_empty_is_empty_regardless_of_focused_node() {
        let none = FullHitTest::empty(None);
        assert!(none.is_empty());
        assert!(none.focused_node.is_none());
        // `is_empty()` is documented to ignore `focused_node` — a focused node
        // must NOT make an unhovered hit test look non-empty.
        let focused = FullHitTest::empty(Some(DomNodeId::ROOT));
        assert!(focused.is_empty());
        assert!(focused.focused_node.is_some());
        assert_eq!(focused.hovered_nodes.len(), 0);
    }
    #[test]
    fn full_hit_test_is_empty_false_once_a_dom_is_hovered() {
        let mut f = FullHitTest::empty(None);
        f.hovered_nodes.insert(DomId::ROOT_ID, HitTest::empty());
        // NOTE: an *empty* HitTest inserted under a DomId still counts as
        // "hovered" — `is_empty()` only looks at the outer map's length.
        assert!(!f.is_empty());
    }
    // ------------------------------------------------------------------
    // PipelineId / DocumentId / ExternalScrollId — constructors + serializers
    // ------------------------------------------------------------------
    #[test]
    fn pipeline_id_new_is_monotonic_and_second_field_is_zero() {
        let a = PipelineId::new();
        let b = PipelineId::new();
        let c = PipelineId::default();
        // The counter only ever moves forward (other tests in this binary may
        // bump it concurrently, so assert ordering, not adjacency).
        assert!(b.0 > a.0);
        assert!(c.0 > b.0);
        assert_eq!(a.1, 0);
        assert_eq!(b.1, 0);
        assert_eq!(PipelineId::DUMMY, PipelineId(0, 0));
    }
    #[test]
    fn pipeline_id_display_and_debug_agree_on_edge_values() {
        assert_eq!(alloc::format!("{}", PipelineId::DUMMY), "PipelineId(0, 0)");
        let maxed = PipelineId(u32::MAX, u32::MAX);
        let shown = alloc::format!("{maxed}");
        assert_eq!(shown, "PipelineId(4294967295, 4294967295)");
        assert_eq!(alloc::format!("{maxed:?}"), shown);
    }
    #[test]
    fn document_id_display_handles_min_and_max() {
        let zero = DocumentId {
            namespace_id: IdNamespace(0),
            id: 0,
        };
        let maxed = DocumentId {
            namespace_id: IdNamespace(u32::MAX),
            id: u32::MAX,
        };
        for d in [zero, maxed] {
            let shown = alloc::format!("{d}");
            assert!(!shown.is_empty());
            assert!(shown.starts_with("DocumentId {"));
            // Debug delegates to Display, so the two must be byte-identical.
            assert_eq!(alloc::format!("{d:?}"), shown);
        }
        assert!(alloc::format!("{maxed}").contains("4294967295"));
    }
    #[test]
    fn external_scroll_id_display_omits_the_pipeline_but_keys_stay_distinct() {
        let a = ExternalScrollId(7, PipelineId(1, 0));
        let b = ExternalScrollId(7, PipelineId(2, 0));
        // Display/Debug only render `.0` — they are deliberately lossy, so two
        // *different* IDs can format identically. Guard that this laxness never
        // leaks into equality/ordering (which is what BTreeMap keys rely on).
        assert_eq!(alloc::format!("{a}"), "ExternalScrollId(7)");
        assert_eq!(alloc::format!("{a:?}"), alloc::format!("{b:?}"));
        assert_ne!(a, b);
        let mut states = ScrollStates::new();
        states.0.insert(a, ScrollState::default());
        states.0.insert(b, ScrollState::default());
        assert_eq!(states.0.len(), 2);
    }
    #[test]
    fn external_scroll_id_display_no_panic_at_u64_max() {
        let shown = alloc::format!("{}", ExternalScrollId(u64::MAX, PipelineId::DUMMY));
        assert_eq!(shown, "ExternalScrollId(18446744073709551615)");
    }
    // ------------------------------------------------------------------
    // max_scroll_rect — numeric edge cases
    // ------------------------------------------------------------------
    #[test]
    fn max_scroll_rect_is_content_minus_viewport_and_keeps_origin() {
        let max = max_scroll_rect(&scroll_node_of(
            r(0.0, 0.0, 100.0, 100.0),
            r(-12.5, 7.25, 400.0, 300.0),
        ));
        assert_eq!(max.size.width, 300.0);
        assert_eq!(max.size.height, 200.0);
        // documented: the returned rect keeps `child_rect.origin`
        assert_eq!(max.origin.x, -12.5);
        assert_eq!(max.origin.y, 7.25);
    }
    #[test]
    fn max_scroll_rect_clamps_negative_range_to_zero() {
        // viewport larger than content -> nothing to scroll (must not go negative)
        let max = max_scroll_rect(&scroll_node_of(
            r(0.0, 0.0, 500.0, 500.0),
            r(0.0, 0.0, 100.0, 100.0),
        ));
        assert_eq!(max.size.width, 0.0);
        assert_eq!(max.size.height, 0.0);
    }
    #[test]
    fn max_scroll_rect_nan_and_infinite_sizes_do_not_produce_nan() {
        // NaN content size: `NaN - x` is NaN, and `.max(0.0)` collapses it to 0.0.
        let nan = max_scroll_rect(&scroll_node_of(
            r(0.0, 0.0, 10.0, 10.0),
            r(0.0, 0.0, f32::NAN, f32::NAN),
        ));
        assert!(!nan.size.width.is_nan());
        assert!(!nan.size.height.is_nan());
        assert_eq!(nan.size.width, 0.0);
        assert_eq!(nan.size.height, 0.0);
        // inf - inf == NaN -> also collapses to 0.0 rather than poisoning scroll.
        let both_inf = max_scroll_rect(&scroll_node_of(
            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
        ));
        assert_eq!(both_inf.size.width, 0.0);
        assert_eq!(both_inf.size.height, 0.0);
        // Infinite content over a finite viewport stays infinite (unbounded scroll).
        let inf = max_scroll_rect(&scroll_node_of(
            r(0.0, 0.0, 10.0, 10.0),
            r(0.0, 0.0, f32::INFINITY, f32::INFINITY),
        ));
        assert!(inf.size.width.is_infinite() && inf.size.width.is_sign_positive());
        assert!(inf.size.height.is_infinite() && inf.size.height.is_sign_positive());
    }
    #[test]
    fn max_scroll_rect_at_float_extremes_does_not_panic() {
        let max = max_scroll_rect(&scroll_node_of(
            r(f32::MIN, f32::MIN, f32::MIN_POSITIVE, f32::MAX),
            r(f32::MAX, f32::MAX, f32::MAX, f32::MIN_POSITIVE),
        ));
        assert!(max.size.width >= 0.0);
        assert!(max.size.height >= 0.0);
        assert!(!max.size.width.is_nan());
        assert!(!max.size.height.is_nan());
    }
    // ------------------------------------------------------------------
    // ScrollState — numeric (zero / negative / min-max / overflow / NaN)
    // ------------------------------------------------------------------
    #[test]
    fn scroll_state_default_and_get_round_trip() {
        let st = ScrollState::default();
        assert_eq!(st.get(), LogicalPosition::zero());
        let st = ScrollState {
            scroll_position: LogicalPosition::new(3.5, -4.25),
        };
        // `get` is a pure accessor: it must not clamp or normalize.
        assert_eq!(st.get().x, 3.5);
        assert_eq!(st.get().y, -4.25);
    }
    #[test]
    fn scroll_state_set_zero_is_identity_within_range() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState::default();
        st.set(0.0, 0.0, &max);
        assert_eq!(st.get(), LogicalPosition::zero());
        st.set(50.0, 150.0, &max);
        assert_eq!(st.get().x, 50.0);
        assert_eq!(st.get().y, 150.0);
    }
    #[test]
    fn scroll_state_set_clamps_negative_to_zero_and_overshoot_to_max() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState::default();
        st.set(-1.0, -f32::MAX, &max);
        assert_eq!(st.get().x, 0.0);
        assert_eq!(st.get().y, 0.0);
        st.set(f32::MAX, f32::MAX, &max);
        assert_eq!(st.get().x, 100.0);
        assert_eq!(st.get().y, 200.0);
        st.set(f32::INFINITY, f32::INFINITY, &max);
        assert_eq!(st.get().x, 100.0);
        assert_eq!(st.get().y, 200.0);
        st.set(f32::NEG_INFINITY, f32::NEG_INFINITY, &max);
        assert_eq!(st.get().x, 0.0);
        assert_eq!(st.get().y, 0.0);
    }
    #[test]
    fn scroll_state_set_nan_position_collapses_to_zero() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState {
            scroll_position: LogicalPosition::new(40.0, 40.0),
        };
        st.set(f32::NAN, f32::NAN, &max);
        assert!(!st.get().x.is_nan());
        assert!(!st.get().y.is_nan());
        assert_eq!(st.get(), LogicalPosition::zero());
    }
    #[test]
    fn scroll_state_set_nan_max_range_collapses_to_zero() {
        // A NaN *range* must not leak into the position either: `NaN.max(0.0)`
        // is 0.0, so the position clamps to 0 rather than becoming NaN.
        let max = r(0.0, 0.0, f32::NAN, f32::NAN);
        let mut st = ScrollState::default();
        st.set(75.0, 75.0, &max);
        assert_eq!(st.get(), LogicalPosition::zero());
    }
    #[test]
    fn scroll_state_set_negative_max_range_collapses_to_zero() {
        let max = r(0.0, 0.0, -50.0, -50.0);
        let mut st = ScrollState::default();
        st.set(10.0, 10.0, &max);
        assert_eq!(st.get(), LogicalPosition::zero());
    }
    #[test]
    fn scroll_state_add_accumulates_then_saturates_at_the_range() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState::default();
        st.add(30.0, 30.0, &max);
        st.add(30.0, 30.0, &max);
        assert_eq!(st.get().x, 60.0);
        assert_eq!(st.get().y, 60.0);
        // Deltas far beyond the f32 range must clamp to the max offset, and
        // re-applying them must not push the position past it (or to +inf).
        st.add(f32::MAX, f32::MAX, &max);
        st.add(f32::MAX, f32::MAX, &max);
        assert_eq!(st.get().x, 100.0);
        assert_eq!(st.get().y, 200.0);
        assert!(st.get().x.is_finite() && st.get().y.is_finite());
    }
    #[test]
    fn scroll_state_add_negative_underflow_clamps_to_zero() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState {
            scroll_position: LogicalPosition::new(10.0, 10.0),
        };
        st.add(f32::MIN, f32::MIN, &max);
        assert_eq!(st.get(), LogicalPosition::zero());
        st.add(f32::NEG_INFINITY, f32::NEG_INFINITY, &max);
        assert_eq!(st.get(), LogicalPosition::zero());
        assert!(st.get().x.is_finite() && st.get().y.is_finite());
    }
    #[test]
    fn scroll_state_add_inf_minus_inf_does_not_poison_position() {
        // Worst case: an unbounded scroll range lets the position itself become
        // +inf, and the *next* delta is -inf -> `inf + -inf == NaN`. The `.max(0.0)`
        // clamp must still collapse that NaN back to a defined 0.0.
        let unbounded = r(0.0, 0.0, f32::INFINITY, f32::INFINITY);
        let mut st = ScrollState::default();
        st.add(f32::INFINITY, f32::INFINITY, &unbounded);
        assert!(st.get().x.is_infinite());
        st.add(f32::NEG_INFINITY, f32::NEG_INFINITY, &unbounded);
        assert!(!st.get().x.is_nan());
        assert!(!st.get().y.is_nan());
        assert_eq!(st.get(), LogicalPosition::zero());
    }
    #[test]
    fn scroll_state_add_nan_delta_from_nonzero_position_resets_to_zero() {
        let max = r(0.0, 0.0, 100.0, 200.0);
        let mut st = ScrollState {
            scroll_position: LogicalPosition::new(50.0, 50.0),
        };
        st.add(f32::NAN, 0.0, &max);
        // `50 + NaN == NaN`, `NaN.max(0.0) == 0.0`: the delta is discarded *and*
        // the previously-good X position is lost. Defined, but lossy — pinned here.
        assert_eq!(st.get().x, 0.0);
        assert_eq!(st.get().y, 50.0);
    }
    // ------------------------------------------------------------------
    // ScrollStates — map behaviour
    // ------------------------------------------------------------------
    #[test]
    fn scroll_states_new_is_empty_and_lookup_misses_return_none() {
        let states = ScrollStates::new();
        assert_eq!(states.0.len(), 0);
        assert!(states.get_scroll_position(&ext_id(0)).is_none());
        assert!(states.get_scroll_position(&ext_id(u64::MAX)).is_none());
    }
    #[test]
    fn scroll_states_set_scroll_position_creates_entry_and_clamps() {
        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 100.0, 300.0));
        let mut states = ScrollStates::new();
        states.set_scroll_position(&node, LogicalPosition::new(999.0, 999.0));
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .expect("entry must exist after set_scroll_position");
        assert_eq!(states.0.len(), 1);
        assert_eq!(pos.x, 0.0); // no horizontal overflow -> zero range
        assert_eq!(pos.y, 200.0); // 300 content - 100 viewport
        // Re-setting the same node updates in place rather than adding a key.
        states.set_scroll_position(&node, LogicalPosition::new(-5.0, -5.0));
        assert_eq!(states.0.len(), 1);
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .unwrap();
        assert_eq!(pos, LogicalPosition::zero());
    }
    #[test]
    fn scroll_states_set_scroll_position_with_nan_stays_defined() {
        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 100.0, 300.0));
        let mut states = ScrollStates::new();
        states.set_scroll_position(&node, LogicalPosition::new(f32::NAN, f32::NAN));
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .unwrap();
        assert!(!pos.x.is_nan() && !pos.y.is_nan());
        assert_eq!(pos, LogicalPosition::zero());
    }
    #[test]
    fn scroll_states_scroll_node_accumulates_and_saturates() {
        let node = scroll_node_of(r(0.0, 0.0, 100.0, 100.0), r(0.0, 0.0, 400.0, 300.0));
        let mut states = ScrollStates::new();
        states.scroll_node(&node, 0.0, 0.0);
        assert_eq!(
            states
                .get_scroll_position(&node.parent_external_scroll_id)
                .unwrap(),
            LogicalPosition::zero()
        );
        states.scroll_node(&node, 10.0, 10.0);
        states.scroll_node(&node, 10.0, 10.0);
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .unwrap();
        assert_eq!(pos.x, 20.0);
        assert_eq!(pos.y, 20.0);
        // Saturate: max range is (400-100, 300-100) = (300, 200).
        states.scroll_node(&node, f32::MAX, f32::INFINITY);
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .unwrap();
        assert_eq!(pos.x, 300.0);
        assert_eq!(pos.y, 200.0);
        // ...and NaN deltas never corrupt the stored position.
        states.scroll_node(&node, f32::NAN, f32::NAN);
        let pos = states
            .get_scroll_position(&node.parent_external_scroll_id)
            .unwrap();
        assert!(!pos.x.is_nan() && !pos.y.is_nan());
        assert_eq!(states.0.len(), 1);
    }
    #[test]
    fn scroll_states_keys_are_pipeline_qualified() {
        // Same raw scroll tag, different pipeline => two independent scroll states.
        let a = OverflowingScrollNode {
            parent_rect: r(0.0, 0.0, 10.0, 10.0),
            child_rect: r(0.0, 0.0, 10.0, 100.0),
            parent_external_scroll_id: ExternalScrollId(1, PipelineId(1, 0)),
            ..Default::default()
        };
        let b = OverflowingScrollNode {
            parent_external_scroll_id: ExternalScrollId(1, PipelineId(2, 0)),
            ..a
        };
        let mut states = ScrollStates::new();
        states.scroll_node(&a, 0.0, 25.0);
        states.scroll_node(&b, 0.0, 50.0);
        assert_eq!(states.0.len(), 2);
        assert_eq!(
            states
                .get_scroll_position(&a.parent_external_scroll_id)
                .unwrap()
                .y,
            25.0
        );
        assert_eq!(
            states
                .get_scroll_position(&b.parent_external_scroll_id)
                .unwrap()
                .y,
            50.0
        );
    }
    // ------------------------------------------------------------------
    // ScrollbarComponent / CursorType — from_u8 over the whole domain
    // ------------------------------------------------------------------
    #[test]
    fn scrollbar_component_from_u8_covers_all_256_values() {
        for v in 0u8..=255 {
            match ScrollbarComponent::from_u8(v) {
                Some(c) => {
                    assert!(v < 4, "value {v} unexpectedly decoded to {c:?}");
                    // discriminant round-trips
                    assert_eq!(c as u8, v);
                }
                None => assert!(v >= 4, "value {v} should have decoded"),
            }
        }
        for c in ALL_SCROLLBAR_COMPONENTS {
            assert_eq!(ScrollbarComponent::from_u8(c as u8), Some(c));
        }
    }
    #[test]
    fn cursor_type_from_u8_is_total_and_unknown_falls_back_to_default() {
        for v in 0u8..=255 {
            let c = CursorType::from_u8(v);
            if v <= 20 {
                assert_eq!(c as u8, v, "known discriminant {v} must round-trip");
            } else {
                // Out-of-range bytes must degrade to Default, never panic.
                assert_eq!(c, CursorType::Default, "unknown byte {v} must be Default");
            }
        }
        assert_eq!(CursorType::default(), CursorType::Default);
        for c in ALL_CURSORS {
            assert_eq!(CursorType::from_u8(c as u8), c);
        }
    }
    // ------------------------------------------------------------------
    // HitTestTag — encode/decode round-trips
    // ------------------------------------------------------------------
    #[test]
    fn dom_node_tag_round_trips_at_u64_boundaries() {
        for inner in [0u64, 1, 673, u64::from(u32::MAX), u64::MAX] {
            let tag = HitTestTag::DomNode {
                tag_id: TagId { inner },
            };
            let item = tag.to_item_tag();
            assert_eq!(item, (inner, TAG_TYPE_DOM_NODE));
            assert_eq!(HitTestTag::from_item_tag(item), Some(tag));
            assert_eq!(tag.as_dom_node().unwrap().inner, inner);
        }
    }
    #[test]
    fn scrollbar_tag_round_trips_for_every_component_at_field_boundaries() {
        let ids = [
            (0usize, 0usize),
            (0, u32::MAX as usize),
            (u32::MAX as usize, 0),
            (u32::MAX as usize, u32::MAX as usize),
        ];
        for component in ALL_SCROLLBAR_COMPONENTS {
            for (dom, node) in ids {
                let tag = HitTestTag::Scrollbar {
                    dom_id: DomId { inner: dom },
                    node_id: NodeId::new(node),
                    component,
                };
                let item = tag.to_item_tag();
                assert_eq!(item.1 & 0xFF00, TAG_TYPE_SCROLLBAR);
                assert_eq!(
                    HitTestTag::from_item_tag(item),
                    Some(tag),
                    "scrollbar round-trip failed for dom={dom} node={node} {component:?}"
                );
            }
        }
    }
    #[test]
    fn cursor_tag_round_trips_for_every_cursor_type() {
        for cursor_type in ALL_CURSORS {
            let tag = HitTestTag::Cursor {
                dom_id: DomId { inner: u32::MAX as usize },
                node_id: NodeId::new(u32::MAX as usize),
                cursor_type,
            };
            let item = tag.to_item_tag();
            assert_eq!(item.1 & 0xFF00, TAG_TYPE_CURSOR);
            assert_eq!(
                HitTestTag::from_item_tag(item),
                Some(tag),
                "cursor round-trip failed for {cursor_type:?}"
            );
        }
    }
    #[test]
    fn selection_tag_round_trips_at_every_field_boundary() {
        let cases = [
            (0usize, 0usize, 0u16),
            (0xFFFF, u32::MAX as usize, u16::MAX),
            (1, 1, 1),
            (0xFFFF, 0, u16::MAX),
        ];
        for (dom, node, run) in cases {
            let tag = HitTestTag::Selection {
                dom_id: DomId { inner: dom },
                container_node_id: NodeId::new(node),
                text_run_index: run,
            };
            let item = tag.to_item_tag();
            assert_eq!(item.1, TAG_TYPE_SELECTION);
            assert_eq!(
                HitTestTag::from_item_tag(item),
                Some(tag),
                "selection round-trip failed for dom={dom} node={node} run={run}"
            );
        }
    }
    #[test]
    fn selection_tag_oversized_node_id_is_masked_not_bled_into_dom_id() {
        // Mirror of the DomId-overflow guard, from the other side: a
        // container_node_id wider than 32 bits must be masked, leaving the
        // DomId field (bits 48..64) intact.
        let tag = HitTestTag::Selection {
            dom_id: DomId { inner: 0x00AB },
            container_node_id: NodeId::new(u32::MAX as usize),
            text_run_index: 0xBEEF,
        };
        let (value, _) = tag.to_item_tag();
        assert_eq!(value >> 48, 0x00AB);
        assert_eq!((value >> 16) & 0xFFFF_FFFF, u64::from(u32::MAX));
        assert_eq!(value & 0xFFFF, 0xBEEF);
    }
    #[test]
    fn tag_namespaces_do_not_collide_on_identical_payloads() {
        // The same numeric payload under four different type markers must decode
        // to four different variants — that is the whole point of the namespaces.
        let payload = 0x0000_0001_0000_0002u64;
        let decoded: [HitTestTag; 4] = [
            HitTestTag::from_item_tag((payload, TAG_TYPE_DOM_NODE)).unwrap(),
            HitTestTag::from_item_tag((payload, TAG_TYPE_SCROLLBAR)).unwrap(),
            HitTestTag::from_item_tag((payload, TAG_TYPE_SELECTION)).unwrap(),
            HitTestTag::from_item_tag((payload, TAG_TYPE_CURSOR)).unwrap(),
        ];
        assert!(decoded[0].is_dom_node());
        assert!(decoded[1].is_scrollbar());
        assert!(decoded[2].is_selection());
        assert!(decoded[3].is_cursor());
        for (i, a) in decoded.iter().enumerate() {
            for b in decoded.iter().skip(i + 1) {
                assert_ne!(a, b);
            }
        }
    }
    // ------------------------------------------------------------------
    // HitTestTag::from_item_tag — malformed / unknown input
    // ------------------------------------------------------------------
    #[test]
    fn from_item_tag_sweeps_every_type_marker_without_panicking() {
        // Exhaustive sweep of the upper byte x a few lower bytes: every
        // combination must either decode or return None — never panic.
        for hi in 0u16..=0xFF {
            for lo in [0u16, 1, 3, 4, 20, 21, 0xFF] {
                let tag_type = (hi << 8) | lo;
                let decoded = HitTestTag::from_item_tag((0xDEAD_BEEF_CAFE_BABE, tag_type));
                let expected_some = match hi {
                    0x00 => tag_type == 0,      // legacy DOM tags only
                    0x01 | 0x03 | 0x04 => true, // DomNode / Selection / Cursor
                    0x02 => lo < 4,             // Scrollbar: component must be valid
                    _ => false,                 // 0x05.. (incl. SCROLL_CONTAINER) is unknown
                };
                assert_eq!(
                    decoded.is_some(),
                    expected_some,
                    "tag_type {tag_type:#06x} decoded to {decoded:?}"
                );
            }
        }
    }
    #[test]
    fn from_item_tag_rejects_invalid_scrollbar_components() {
        for lo in 4u16..=0xFF {
            let item = (0u64, TAG_TYPE_SCROLLBAR | lo);
            assert!(
                HitTestTag::from_item_tag(item).is_none(),
                "scrollbar component byte {lo} should be rejected"
            );
        }
    }
    #[test]
    fn from_item_tag_unknown_cursor_byte_degrades_to_default() {
        // Unlike scrollbars, an unknown cursor byte is *not* an error: it maps
        // to CursorType::Default so a corrupt tag still yields a usable cursor.
        for lo in [21u16, 100, 0xFF] {
            let decoded = HitTestTag::from_item_tag((0, TAG_TYPE_CURSOR | lo)).unwrap();
            assert_eq!(
                decoded.as_cursor().unwrap().2,
                CursorType::Default,
                "cursor byte {lo} should fall back to Default"
            );
        }
    }
    #[test]
    fn from_item_tag_dom_node_ignores_the_lower_byte() {
        // Only the upper byte selects the namespace; junk in the lower byte of a
        // DOM-node tag must not flip it to another variant or drop it.
        for lo in [0u16, 1, 0x7F, 0xFF] {
            let decoded = HitTestTag::from_item_tag((99, TAG_TYPE_DOM_NODE | lo)).unwrap();
            assert!(decoded.is_dom_node());
            assert_eq!(decoded.as_dom_node().unwrap().inner, 99);
        }
    }
    #[test]
    fn from_item_tag_scroll_container_marker_is_not_decodable() {
        // TAG_TYPE_SCROLL_CONTAINER has no HitTestTag variant — it must be
        // rejected rather than silently aliased onto another namespace.
        assert!(HitTestTag::from_item_tag((0, TAG_TYPE_SCROLL_CONTAINER)).is_none());
        assert!(HitTestTag::from_item_tag((u64::MAX, TAG_TYPE_SCROLL_CONTAINER)).is_none());
    }
    #[test]
    fn from_item_tag_legacy_zero_type_only_matches_exact_zero() {
        // tag_type == 0 is the legacy DOM-node escape hatch...
        let legacy = HitTestTag::from_item_tag((u64::MAX, 0)).unwrap();
        assert_eq!(legacy.as_dom_node().unwrap().inner, u64::MAX);
        // ...but a *nonzero* lower byte with a zero upper byte is not a known
        // namespace and must be rejected, not treated as legacy.
        for lo in 1u16..=0xFF {
            assert!(
                HitTestTag::from_item_tag((0, lo)).is_none(),
                "tag_type {lo:#06x} must not be treated as a legacy DOM tag"
            );
        }
    }
    #[cfg(target_pointer_width = "64")]
    #[test]
    fn scrollbar_encode_keeps_decoded_fields_inside_their_bit_windows() {
        // A NodeId wider than 32 bits is absurd, but must degrade without
        // panicking (no shift/`as` overflow) and must not produce out-of-window
        // decoded values. NOTE: unlike `Selection`, the `Scrollbar`/`Cursor`
        // encoders do NOT mask their fields, so such an id is lossy — see report.
        let tag = HitTestTag::Scrollbar {
            dom_id: DomId { inner: 0 },
            node_id: NodeId::new(1usize << 32),
            component: ScrollbarComponent::VerticalTrack,
        };
        let (value, ty) = tag.to_item_tag();
        assert_eq!(ty & 0xFF00, TAG_TYPE_SCROLLBAR);
        let decoded = HitTestTag::from_item_tag((value, ty)).expect("must still decode");
        let (dom, node, component) = decoded.as_scrollbar().unwrap();
        assert!(dom.inner <= u32::MAX as usize);
        assert!(node.index() <= u32::MAX as usize);
        assert_eq!(component, ScrollbarComponent::VerticalTrack);
    }
    // ------------------------------------------------------------------
    // HitTestTag — predicates + accessors
    // ------------------------------------------------------------------
    fn sample_tags() -> [HitTestTag; 4] {
        [
            HitTestTag::DomNode {
                tag_id: TagId { inner: 7 },
            },
            HitTestTag::Scrollbar {
                dom_id: DomId { inner: 1 },
                node_id: NodeId::new(2),
                component: ScrollbarComponent::HorizontalThumb,
            },
            HitTestTag::Cursor {
                dom_id: DomId { inner: 3 },
                node_id: NodeId::new(4),
                cursor_type: CursorType::Grabbing,
            },
            HitTestTag::Selection {
                dom_id: DomId { inner: 5 },
                container_node_id: NodeId::new(6),
                text_run_index: 8,
            },
        ]
    }
    #[test]
    fn exactly_one_predicate_is_true_per_variant() {
        for tag in sample_tags() {
            let flags = [
                tag.is_dom_node(),
                tag.is_scrollbar(),
                tag.is_cursor(),
                tag.is_selection(),
            ];
            assert_eq!(
                flags.iter().filter(|b| **b).count(),
                1,
                "predicates not mutually exclusive for {tag:?}"
            );
        }
    }
    #[test]
    fn accessors_agree_with_predicates_and_return_none_otherwise() {
        for tag in sample_tags() {
            assert_eq!(tag.as_dom_node().is_some(), tag.is_dom_node());
            assert_eq!(tag.as_scrollbar().is_some(), tag.is_scrollbar());
            assert_eq!(tag.as_cursor().is_some(), tag.is_cursor());
            assert_eq!(tag.as_selection().is_some(), tag.is_selection());
            // exactly one accessor yields a value
            let some_count = usize::from(tag.as_dom_node().is_some())
                + usize::from(tag.as_scrollbar().is_some())
                + usize::from(tag.as_cursor().is_some())
                + usize::from(tag.as_selection().is_some());
            assert_eq!(some_count, 1, "accessor overlap for {tag:?}");
        }
    }
    #[test]
    fn accessors_return_the_constructed_payload() {
        let [dom, scrollbar, cursor, selection] = sample_tags();
        assert_eq!(dom.as_dom_node().unwrap().inner, 7);
        let (d, n, c) = scrollbar.as_scrollbar().unwrap();
        assert_eq!((d.inner, n.index()), (1, 2));
        assert_eq!(c, ScrollbarComponent::HorizontalThumb);
        let (d, n, c) = cursor.as_cursor().unwrap();
        assert_eq!((d.inner, n.index()), (3, 4));
        assert_eq!(c, CursorType::Grabbing);
        let (d, n, run) = selection.as_selection().unwrap();
        assert_eq!((d.inner, n.index()), (5, 6));
        assert_eq!(run, 8);
    }
    // ------------------------------------------------------------------
    // HitTestTag — Display
    // ------------------------------------------------------------------
    #[test]
    fn hit_test_tag_display_is_non_empty_and_variant_tagged() {
        let [dom, scrollbar, cursor, selection] = sample_tags();
        for (tag, prefix) in [
            (dom, "DomNode("),
            (scrollbar, "Scrollbar("),
            (cursor, "Cursor("),
            (selection, "Selection("),
        ] {
            let shown = alloc::format!("{tag}");
            assert!(!shown.is_empty());
            assert!(
                shown.starts_with(prefix),
                "expected {shown:?} to start with {prefix:?}"
            );
        }
    }
    #[test]
    fn hit_test_tag_display_handles_extreme_ids() {
        let tags = [
            HitTestTag::DomNode {
                tag_id: TagId { inner: u64::MAX },
            },
            HitTestTag::Scrollbar {
                dom_id: DomId { inner: usize::MAX },
                node_id: NodeId::new(usize::MAX),
                component: ScrollbarComponent::VerticalThumb,
            },
            HitTestTag::Cursor {
                dom_id: DomId { inner: usize::MAX },
                node_id: NodeId::new(usize::MAX),
                cursor_type: CursorType::Progress,
            },
            HitTestTag::Selection {
                dom_id: DomId { inner: usize::MAX },
                container_node_id: NodeId::new(usize::MAX),
                text_run_index: u16::MAX,
            },
        ];
        for tag in tags {
            assert!(!alloc::format!("{tag}").is_empty());
            assert!(!alloc::format!("{tag:?}").is_empty());
            // encoding an extreme tag must not panic either
            let _ = tag.to_item_tag();
        }
    }
}