1
//! Unified drag context for all drag operations.
2
//!
3
//! This module provides a single, coherent way to handle all drag operations:
4
//! - Text selection drag
5
//! - Scrollbar thumb drag
6
//! - Node drag-and-drop
7
//! - Window drag/resize
8
//! - File drop from OS
9
//!
10
//! The `DragContext` struct tracks the current drag state and provides
11
//! a unified interface for event processing.
12

            
13
use alloc::vec::Vec;
14

            
15
use crate::dom::{DomId, DomNodeId, NodeId, OptionDomNodeId};
16
use crate::geom::LogicalPosition;
17
use crate::selection::TextCursor;
18
use crate::window::WindowPosition;
19

            
20
use azul_css::{AzString, StringVec, U8Vec};
21

            
22
/// Type of the active drag operation.
23
///
24
/// This enum unifies all drag types into a single discriminated union,
25
/// making it easy to handle different drag behaviors in one place.
26
#[derive(Debug, Clone, PartialEq)]
27
#[repr(C, u8)]
28
pub enum ActiveDragType {
29
    /// Text selection drag - user is selecting text by dragging
30
    TextSelection(TextSelectionDrag),
31
    /// Scrollbar thumb drag - user is dragging a scrollbar thumb
32
    ScrollbarThumb(ScrollbarThumbDrag),
33
    /// Node drag-and-drop - user is dragging a DOM node
34
    Node(NodeDrag),
35
    /// Window drag - user is moving the window (titlebar drag)
36
    WindowMove(WindowMoveDrag),
37
    /// Window resize - user is resizing the window (edge/corner drag)
38
    WindowResize(WindowResizeDrag),
39
    /// File drop from OS - user is dragging file(s) from the OS
40
    FileDrop(FileDropDrag),
41
}
42

            
43
/// Text selection drag state.
44
///
45
/// Tracks the anchor point (where selection started) and current position.
46
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47
#[repr(C)]
48
pub struct TextSelectionDrag {
49
    /// DOM ID where the selection started
50
    pub dom_id: DomId,
51
    /// The IFC root node where selection started (e.g., <p> element)
52
    pub anchor_ifc_node: NodeId,
53
    /// The anchor cursor position (fixed during drag)
54
    pub anchor_cursor: Option<TextCursor>,
55
    /// Mouse position where drag started
56
    pub start_mouse_position: LogicalPosition,
57
    /// Current mouse position
58
    pub current_mouse_position: LogicalPosition,
59
}
60

            
61
/// Scrollbar thumb drag state.
62
///
63
/// Tracks which scrollbar is being dragged and the current offset.
64
#[derive(Debug, Clone, Copy, PartialEq)]
65
#[repr(C)]
66
pub struct ScrollbarThumbDrag {
67
    /// DOM ID that `scroll_container_node` belongs to. Used to scope
68
    /// `remap_node_ids` so a reconciliation of a *different* DOM can't remap
69
    /// this drag's node id against an unrelated DOM's old→new map.
70
    pub dom_id: DomId,
71
    /// The scroll container node being scrolled
72
    pub scroll_container_node: NodeId,
73
    /// Whether this is the vertical or horizontal scrollbar
74
    pub axis: ScrollbarAxis,
75
    /// Mouse Y position where drag started (for calculating delta)
76
    pub start_mouse_position: LogicalPosition,
77
    /// Scroll offset when drag started
78
    pub start_scroll_offset: f32,
79
    /// Current mouse position
80
    pub current_mouse_position: LogicalPosition,
81
    /// Track length in pixels (for calculating scroll ratio)
82
    pub track_length_px: f32,
83
    /// Content length in pixels (for calculating scroll ratio)
84
    pub content_length_px: f32,
85
    /// Viewport length in pixels (for calculating scroll ratio)
86
    pub viewport_length_px: f32,
87
}
88

            
89
/// Which scrollbar axis is being dragged
90
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91
#[repr(C)]
92
pub enum ScrollbarAxis {
93
    Vertical,
94
    Horizontal,
95
}
96

            
97
/// Node drag-and-drop state.
98
///
99
/// Tracks a DOM node being dragged for reordering or moving.
100
#[derive(Debug, Clone, PartialEq, Eq)]
101
#[repr(C)]
102
pub struct NodeDrag {
103
    /// DOM ID of the node being dragged
104
    pub dom_id: DomId,
105
    /// Node ID being dragged
106
    pub node_id: NodeId,
107
    /// Position where drag started
108
    pub start_position: LogicalPosition,
109
    /// Current drag position
110
    pub current_position: LogicalPosition,
111
    /// Offset from node origin to click point (for correct visual positioning)
112
    pub drag_offset: LogicalPosition,
113
    /// Optional: DOM node currently under cursor (drop target)
114
    pub current_drop_target: OptionDomNodeId,
115
    /// Previous drop target (for generating DragEnter/DragLeave events)
116
    pub previous_drop_target: OptionDomNodeId,
117
    /// Drag data (MIME types and content)
118
    pub drag_data: DragData,
119
    /// Whether the current drop target has accepted the drop via `accept_drop()`
120
    pub drop_accepted: bool,
121
    /// Drop effect set by the drop target
122
    pub drop_effect: DropEffect,
123
}
124

            
125
/// Window move drag state.
126
///
127
/// Tracks the window being moved via titlebar drag.
128
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129
#[repr(C)]
130
pub struct WindowMoveDrag {
131
    /// Position where window drag started (in screen coordinates)
132
    pub start_position: LogicalPosition,
133
    /// Current drag position
134
    pub current_position: LogicalPosition,
135
    /// Initial window position before drag
136
    pub initial_window_position: WindowPosition,
137
}
138

            
139
/// Window resize drag state.
140
///
141
/// Tracks the window being resized via edge/corner drag.
142
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143
#[repr(C)]
144
pub struct WindowResizeDrag {
145
    /// Which edge/corner is being dragged
146
    pub edge: WindowResizeEdge,
147
    /// Position where resize started
148
    pub start_position: LogicalPosition,
149
    /// Current drag position
150
    pub current_position: LogicalPosition,
151
    /// Initial window size before resize
152
    pub initial_width: u32,
153
    /// Initial window height before resize
154
    pub initial_height: u32,
155
}
156

            
157
/// Which edge or corner of the window is being resized
158
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159
#[repr(C)]
160
pub enum WindowResizeEdge {
161
    Top,
162
    Bottom,
163
    Left,
164
    Right,
165
    TopLeft,
166
    TopRight,
167
    BottomLeft,
168
    BottomRight,
169
}
170

            
171
/// File drop from OS drag state.
172
///
173
/// Tracks files being dragged from the operating system.
174
#[derive(Debug, Clone, PartialEq, Eq)]
175
#[repr(C)]
176
pub struct FileDropDrag {
177
    /// Files being dragged (as string paths)
178
    pub files: StringVec,
179
    /// Current position of drag cursor
180
    pub position: LogicalPosition,
181
    /// DOM node under cursor (potential drop target)
182
    pub drop_target: OptionDomNodeId,
183
    /// Allowed drop effect
184
    pub drop_effect: DropEffect,
185
}
186

            
187

            
188
/// Drop effect — the operation that will happen if the data is dropped
189
/// on the current target (HTML5 `DataTransfer.dropEffect`).
190
///
191
/// This is a strict subset of `DragEffect`: a drop target selects one of
192
/// these four outcomes, which must also be allowed by the source's
193
/// `effect_allowed`.
194
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195
#[repr(C)]
196
pub enum DropEffect {
197
    /// No drop allowed / the drop is rejected. Default.
198
    #[default]
199
    None,
200
    /// Drop will copy the data (source retains its copy).
201
    Copy,
202
    /// Drop will create a link/shortcut to the data.
203
    Link,
204
    /// Drop will move the data (source should remove its copy).
205
    Move,
206
}
207

            
208
/// Allowed drag effects — the set of operations the drag source permits
209
/// (HTML5 `DataTransfer.effectAllowed`).
210
///
211
/// The drop target's `DropEffect` must be a member of this set for the
212
/// drop to succeed. Semantic superset of `DropEffect` that adds the
213
/// HTML5 combined-permission values (`CopyLink`, `CopyMove`, `LinkMove`,
214
/// `All`) and the pre-drag `Uninitialized` sentinel.
215
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
216
#[repr(C)]
217
pub enum DragEffect {
218
    /// Allowed set has not been initialized yet (equivalent to `All` in
219
    /// most user agents). Default for fresh drags.
220
    #[default]
221
    Uninitialized,
222
    /// No drop is permitted.
223
    None,
224
    /// Only Copy is permitted.
225
    Copy,
226
    /// Copy or Link is permitted.
227
    CopyLink,
228
    /// Copy or Move is permitted.
229
    CopyMove,
230
    /// Only Link is permitted.
231
    Link,
232
    /// Link or Move is permitted.
233
    LinkMove,
234
    /// Only Move is permitted.
235
    Move,
236
    /// Any of Copy, Link, or Move is permitted.
237
    All,
238
}
239

            
240
/// FFI-safe (`mime_type`, `data`) pair used by [`DragData`] in place of
241
/// a `BTreeMap<AzString, Vec<u8>>` entry.
242
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
243
#[repr(C)]
244
pub struct MimeTypeData {
245
    pub mime_type: AzString,
246
    pub data: U8Vec,
247
}
248

            
249
impl_option!(
250
    MimeTypeData,
251
    OptionMimeTypeData,
252
    copy = false,
253
    [Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash]
254
);
255

            
256
impl_vec!(
257
    MimeTypeData,
258
    MimeTypeDataVec,
259
    MimeTypeDataVecDestructor,
260
    MimeTypeDataVecDestructorType,
261
    MimeTypeDataVecSlice,
262
    OptionMimeTypeData
263
);
264
impl_vec_mut!(MimeTypeData, MimeTypeDataVec);
265
impl_vec_debug!(MimeTypeData, MimeTypeDataVec);
266
impl_vec_partialord!(MimeTypeData, MimeTypeDataVec);
267
impl_vec_ord!(MimeTypeData, MimeTypeDataVec);
268
impl_vec_clone!(MimeTypeData, MimeTypeDataVec, MimeTypeDataVecDestructor);
269
impl_vec_partialeq!(MimeTypeData, MimeTypeDataVec);
270
impl_vec_eq!(MimeTypeData, MimeTypeDataVec);
271
impl_vec_hash!(MimeTypeData, MimeTypeDataVec);
272

            
273
/// Drag data (HTML5 `DataTransfer`).
274
///
275
/// Holds the payload(s) being transferred during a drag operation, keyed
276
/// by MIME type, plus the set of operations the source allows.
277
#[derive(Debug, Default, Clone, PartialEq, Eq)]
278
#[repr(C)]
279
pub struct DragData {
280
    /// MIME type -> data mapping (vec-of-pairs for FFI compatibility).
281
    ///
282
    /// e.g., `"text/plain" -> "Hello World"`.
283
    pub data: MimeTypeDataVec,
284
    /// Set of drag operations the source permits for this drag.
285
    pub effect_allowed: DragEffect,
286
}
287

            
288
impl DragData {
289
    /// Create new empty drag data
290
73
    #[must_use] pub const fn new() -> Self {
291
73
        Self {
292
73
            data: MimeTypeDataVec::new(),
293
73
            effect_allowed: DragEffect::Uninitialized,
294
73
        }
295
73
    }
296

            
297
    /// Set data for a MIME type. Replaces any existing entry for the
298
    /// same MIME type.
299
1038
    pub fn set_data(&mut self, mime_type: impl Into<AzString>, data: Vec<u8>) {
300
1038
        let mime_type = mime_type.into();
301
1038
        let value: U8Vec = data.into();
302
1038
        if let Some(entry) = self
303
1038
            .data
304
1038
            .as_mut()
305
1038
            .iter_mut()
306
499508
            .find(|e| e.mime_type == mime_type)
307
3
        {
308
3
            entry.data = value;
309
1035
        } else {
310
1035
            self.data.push(MimeTypeData {
311
1035
                mime_type,
312
1035
                data: value,
313
1035
            });
314
1035
        }
315
1038
    }
316

            
317
    /// Get data for a MIME type
318
81
    #[must_use] pub fn get_data(&self, mime_type: &str) -> Option<&[u8]> {
319
81
        self.data
320
81
            .as_ref()
321
81
            .iter()
322
2079
            .find(|e| e.mime_type.as_str() == mime_type)
323
81
            .map(|e| e.data.as_ref())
324
81
    }
325

            
326
    /// Set plain text data
327
15
    pub fn set_text(&mut self, text: impl Into<AzString>) {
328
15
        let text_str = text.into();
329
15
        self.set_data("text/plain", text_str.as_str().as_bytes().to_vec());
330
15
    }
331

            
332
    /// Get plain text data
333
17
    #[must_use] pub fn get_text(&self) -> Option<AzString> {
334
17
        self.get_data("text/plain")
335
17
            .map(|bytes| AzString::from(core::str::from_utf8(bytes).unwrap_or("")))
336
17
    }
337
}
338

            
339
/// The unified drag context.
340
///
341
/// This struct wraps `ActiveDragType` and provides common metadata
342
/// that applies to all drag operations.
343
///
344
/// Note: this type is Rust-only and not exposed through the C API.
345
#[derive(Debug, Clone, PartialEq)]
346
pub struct DragContext {
347
    /// The specific type of drag operation
348
    pub drag_type: ActiveDragType,
349
    /// Session ID from gesture detection (links back to `GestureManager`)
350
    pub session_id: u64,
351
    /// Whether the drag has been cancelled (e.g., Escape pressed)
352
    pub cancelled: bool,
353
}
354

            
355
impl DragContext {
356
    /// Create a new drag context
357
813
    #[must_use] pub const fn new(drag_type: ActiveDragType, session_id: u64) -> Self {
358
813
        Self {
359
813
            drag_type,
360
813
            session_id,
361
813
            cancelled: false,
362
813
        }
363
813
    }
364

            
365
    /// Create a text selection drag
366
14
    #[must_use] pub const fn text_selection(
367
14
        dom_id: DomId,
368
14
        anchor_ifc_node: NodeId,
369
14
        start_mouse_position: LogicalPosition,
370
14
        session_id: u64,
371
14
    ) -> Self {
372
14
        Self::new(
373
14
            ActiveDragType::TextSelection(TextSelectionDrag {
374
14
                dom_id,
375
14
                anchor_ifc_node,
376
14
                anchor_cursor: None,
377
14
                start_mouse_position,
378
14
                current_mouse_position: start_mouse_position,
379
14
            }),
380
14
            session_id,
381
        )
382
14
    }
383

            
384
    /// Create a scrollbar thumb drag
385
46
    #[must_use] pub const fn scrollbar_thumb(
386
46
        dom_id: DomId,
387
46
        scroll_container_node: NodeId,
388
46
        axis: ScrollbarAxis,
389
46
        start_mouse_position: LogicalPosition,
390
46
        start_scroll_offset: f32,
391
46
        track_length_px: f32,
392
46
        content_length_px: f32,
393
46
        viewport_length_px: f32,
394
46
        session_id: u64,
395
46
    ) -> Self {
396
46
        Self::new(
397
46
            ActiveDragType::ScrollbarThumb(ScrollbarThumbDrag {
398
46
                dom_id,
399
46
                scroll_container_node,
400
46
                axis,
401
46
                start_mouse_position,
402
46
                start_scroll_offset,
403
46
                current_mouse_position: start_mouse_position,
404
46
                track_length_px,
405
46
                content_length_px,
406
46
                viewport_length_px,
407
46
            }),
408
46
            session_id,
409
        )
410
46
    }
411

            
412
    /// Create a node drag
413
275
    #[must_use] pub const fn node_drag(
414
275
        dom_id: DomId,
415
275
        node_id: NodeId,
416
275
        start_position: LogicalPosition,
417
275
        drag_data: DragData,
418
275
        session_id: u64,
419
275
    ) -> Self {
420
275
        Self::new(
421
275
            ActiveDragType::Node(NodeDrag {
422
275
                dom_id,
423
275
                node_id,
424
275
                start_position,
425
275
                current_position: start_position,
426
275
                drag_offset: LogicalPosition::zero(),
427
275
                current_drop_target: OptionDomNodeId::None,
428
275
                previous_drop_target: OptionDomNodeId::None,
429
275
                drag_data,
430
275
                drop_accepted: false,
431
275
                drop_effect: DropEffect::None,
432
275
            }),
433
275
            session_id,
434
        )
435
275
    }
436

            
437
    /// Create a window move drag
438
70
    #[must_use] pub const fn window_move(
439
70
        start_position: LogicalPosition,
440
70
        initial_window_position: WindowPosition,
441
70
        session_id: u64,
442
70
    ) -> Self {
443
70
        Self::new(
444
70
            ActiveDragType::WindowMove(WindowMoveDrag {
445
70
                start_position,
446
70
                current_position: start_position,
447
70
                initial_window_position,
448
70
            }),
449
70
            session_id,
450
        )
451
70
    }
452

            
453
    /// Create a file drop drag
454
29
    #[must_use] pub fn file_drop(files: Vec<AzString>, position: LogicalPosition, session_id: u64) -> Self {
455
29
        Self::new(
456
29
            ActiveDragType::FileDrop(FileDropDrag {
457
29
                files: files.into(),
458
29
                position,
459
29
                drop_target: OptionDomNodeId::None,
460
29
                drop_effect: DropEffect::Copy,
461
29
            }),
462
29
            session_id,
463
        )
464
29
    }
465

            
466
    /// Update the current mouse position for all drag types
467
1058
    pub const fn update_position(&mut self, position: LogicalPosition) {
468
1058
        match &mut self.drag_type {
469
3
            ActiveDragType::TextSelection(ref mut drag) => {
470
3
                drag.current_mouse_position = position;
471
3
            }
472
1037
            ActiveDragType::ScrollbarThumb(ref mut drag) => {
473
1037
                drag.current_mouse_position = position;
474
1037
            }
475
4
            ActiveDragType::Node(ref mut drag) => {
476
4
                drag.current_position = position;
477
4
            }
478
7
            ActiveDragType::WindowMove(ref mut drag) => {
479
7
                drag.current_position = position;
480
7
            }
481
4
            ActiveDragType::WindowResize(ref mut drag) => {
482
4
                drag.current_position = position;
483
4
            }
484
3
            ActiveDragType::FileDrop(ref mut drag) => {
485
3
                drag.position = position;
486
3
            }
487
        }
488
1058
    }
489

            
490
    /// Get the current mouse position
491
23
    #[must_use] pub const fn current_position(&self) -> LogicalPosition {
492
23
        match &self.drag_type {
493
3
            ActiveDragType::TextSelection(drag) => drag.current_mouse_position,
494
4
            ActiveDragType::ScrollbarThumb(drag) => drag.current_mouse_position,
495
4
            ActiveDragType::Node(drag) => drag.current_position,
496
3
            ActiveDragType::WindowMove(drag) => drag.current_position,
497
6
            ActiveDragType::WindowResize(drag) => drag.current_position,
498
3
            ActiveDragType::FileDrop(drag) => drag.position,
499
        }
500
23
    }
501

            
502
    /// Get the start position
503
15
    #[must_use] pub const fn start_position(&self) -> LogicalPosition {
504
15
        match &self.drag_type {
505
2
            ActiveDragType::TextSelection(drag) => drag.start_mouse_position,
506
3
            ActiveDragType::ScrollbarThumb(drag) => drag.start_mouse_position,
507
2
            ActiveDragType::Node(drag) => drag.start_position,
508
2
            ActiveDragType::WindowMove(drag) => drag.start_position,
509
4
            ActiveDragType::WindowResize(drag) => drag.start_position,
510
2
            ActiveDragType::FileDrop(drag) => drag.position, // No start for file drops
511
        }
512
15
    }
513

            
514
    /// Check if this is a text selection drag
515
9
    #[must_use] pub const fn is_text_selection(&self) -> bool {
516
9
        matches!(self.drag_type, ActiveDragType::TextSelection(_))
517
9
    }
518

            
519
    /// Check if this is a scrollbar thumb drag
520
9
    #[must_use] pub const fn is_scrollbar_thumb(&self) -> bool {
521
9
        matches!(self.drag_type, ActiveDragType::ScrollbarThumb(_))
522
9
    }
523

            
524
    /// Check if this is a node drag
525
36
    #[must_use] pub const fn is_node_drag(&self) -> bool {
526
36
        matches!(self.drag_type, ActiveDragType::Node(_))
527
36
    }
528

            
529
    /// Check if this is a window move drag
530
12
    #[must_use] pub const fn is_window_move(&self) -> bool {
531
12
        matches!(self.drag_type, ActiveDragType::WindowMove(_))
532
12
    }
533

            
534
    /// Check if this is a file drop
535
7
    #[must_use] pub const fn is_file_drop(&self) -> bool {
536
7
        matches!(self.drag_type, ActiveDragType::FileDrop(_))
537
7
    }
538

            
539
    /// Get as text selection drag (if applicable)
540
14
    #[must_use] pub const fn as_text_selection(&self) -> Option<&TextSelectionDrag> {
541
14
        match &self.drag_type {
542
8
            ActiveDragType::TextSelection(drag) => Some(drag),
543
6
            _ => None,
544
        }
545
14
    }
546

            
547
    /// Get as mutable text selection drag (if applicable)
548
7
    pub const fn as_text_selection_mut(&mut self) -> Option<&mut TextSelectionDrag> {
549
7
        match &mut self.drag_type {
550
2
            ActiveDragType::TextSelection(drag) => Some(drag),
551
5
            _ => None,
552
        }
553
7
    }
554

            
555
    /// Get as scrollbar thumb drag (if applicable)
556
139
    #[must_use] pub const fn as_scrollbar_thumb(&self) -> Option<&ScrollbarThumbDrag> {
557
139
        match &self.drag_type {
558
118
            ActiveDragType::ScrollbarThumb(drag) => Some(drag),
559
21
            _ => None,
560
        }
561
139
    }
562

            
563
    /// Get as mutable scrollbar thumb drag (if applicable)
564
7
    pub const fn as_scrollbar_thumb_mut(&mut self) -> Option<&mut ScrollbarThumbDrag> {
565
7
        match &mut self.drag_type {
566
2
            ActiveDragType::ScrollbarThumb(drag) => Some(drag),
567
5
            _ => None,
568
        }
569
7
    }
570

            
571
    /// Get as node drag (if applicable)
572
84
    #[must_use] pub const fn as_node_drag(&self) -> Option<&NodeDrag> {
573
84
        match &self.drag_type {
574
79
            ActiveDragType::Node(drag) => Some(drag),
575
5
            _ => None,
576
        }
577
84
    }
578

            
579
    /// Get as mutable node drag (if applicable)
580
13
    pub const fn as_node_drag_mut(&mut self) -> Option<&mut NodeDrag> {
581
13
        match &mut self.drag_type {
582
8
            ActiveDragType::Node(drag) => Some(drag),
583
5
            _ => None,
584
        }
585
13
    }
586

            
587
    /// Get as window move drag (if applicable)
588
20
    #[must_use] pub const fn as_window_move(&self) -> Option<&WindowMoveDrag> {
589
20
        match &self.drag_type {
590
13
            ActiveDragType::WindowMove(drag) => Some(drag),
591
7
            _ => None,
592
        }
593
20
    }
594

            
595
    /// Get as file drop (if applicable)
596
14
    #[must_use] pub const fn as_file_drop(&self) -> Option<&FileDropDrag> {
597
14
        match &self.drag_type {
598
9
            ActiveDragType::FileDrop(drag) => Some(drag),
599
5
            _ => None,
600
        }
601
14
    }
602

            
603
    /// Get as mutable file drop (if applicable)
604
8
    pub const fn as_file_drop_mut(&mut self) -> Option<&mut FileDropDrag> {
605
8
        match &mut self.drag_type {
606
3
            ActiveDragType::FileDrop(drag) => Some(drag),
607
5
            _ => None,
608
        }
609
8
    }
610

            
611
    /// Calculate scroll delta for scrollbar thumb drag
612
    ///
613
    /// Returns the new scroll offset based on current mouse position.
614
126
    #[must_use] pub fn calculate_scrollbar_scroll_offset(&self) -> Option<f32> {
615
126
        let drag = self.as_scrollbar_thumb()?;
616
        
617
        // Calculate mouse delta along the drag axis
618
110
        let mouse_delta = match drag.axis {
619
            ScrollbarAxis::Vertical => {
620
108
                drag.current_mouse_position.y - drag.start_mouse_position.y
621
            }
622
            ScrollbarAxis::Horizontal => {
623
2
                drag.current_mouse_position.x - drag.start_mouse_position.x
624
            }
625
        };
626

            
627
        // Calculate the scrollable range
628
110
        let scrollable_range = drag.content_length_px - drag.viewport_length_px;
629
        // The explicit `is_nan()` (equivalent to the old `!(x > 0.0)`) catches a NaN
630
        // scrollable_range — from a NaN, or inf-minus-inf, content/viewport length —
631
        // so it never reaches the `clamp(0.0, scrollable_range)` below, whose
632
        // f32::clamp would panic (it asserts min <= max, and NaN fails every compare).
633
110
        if scrollable_range <= 0.0 || scrollable_range.is_nan() || drag.track_length_px <= 0.0 {
634
33
            return Some(drag.start_scroll_offset);
635
77
        }
636

            
637
        // Calculate thumb length (proportional to viewport/content ratio)
638
77
        let thumb_length = (drag.viewport_length_px / drag.content_length_px) * drag.track_length_px;
639
77
        let scrollable_track = drag.track_length_px - thumb_length;
640

            
641
77
        if scrollable_track <= 0.0 {
642
            return Some(drag.start_scroll_offset);
643
77
        }
644

            
645
        // Convert mouse delta to scroll delta
646
77
        let scroll_ratio = mouse_delta / scrollable_track;
647
77
        let scroll_delta = scroll_ratio * scrollable_range;
648

            
649
        // Calculate new scroll offset
650
77
        let new_offset = drag.start_scroll_offset + scroll_delta;
651

            
652
        // Clamp to valid range
653
77
        Some(new_offset.clamp(0.0, scrollable_range))
654
126
    }
655

            
656
    /// Remap a drop target's `NodeId` using the old→new mapping.
657
    /// Clears the target if the old `NodeId` was removed.
658
40
    fn remap_drop_target(
659
40
        target: &mut OptionDomNodeId,
660
40
        dom_id: DomId,
661
40
        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
662
40
    ) {
663
40
        let dt = match target.into_option() {
664
14
            Some(dt) if dt.dom == dom_id => dt,
665
28
            _ => return,
666
        };
667
12
        let Some(old_nid) = dt.node.into_crate_internal() else {
668
1
            return;
669
        };
670
11
        if let Some(&new_nid) = node_id_map.get(&old_nid) {
671
7
            *target = Some(DomNodeId {
672
7
                dom: dom_id,
673
7
                node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(new_nid)),
674
7
            }).into();
675
7
        } else {
676
4
            *target = OptionDomNodeId::None;
677
4
        }
678
40
    }
679

            
680
    /// Remap `NodeIds` stored in this drag context after DOM reconciliation.
681
    ///
682
    /// When the DOM is regenerated during an active drag, `NodeIds` can change.
683
    /// This updates all stored `NodeIds` using the old→new mapping.
684
    /// Returns `false` if a critical `NodeId` was removed (drag should be cancelled).
685
44
    pub fn remap_node_ids(
686
44
        &mut self,
687
44
        dom_id: DomId,
688
44
        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
689
44
    ) -> bool {
690
44
        match &mut self.drag_type {
691
5
            ActiveDragType::TextSelection(ref mut drag) => {
692
5
                if drag.dom_id != dom_id {
693
1
                    return true;
694
4
                }
695
4
                if let Some(&new_id) = node_id_map.get(&drag.anchor_ifc_node) {
696
2
                    drag.anchor_ifc_node = new_id;
697
2
                } else {
698
2
                    return false; // anchor node removed
699
                }
700
2
                true
701
            }
702
4
            ActiveDragType::ScrollbarThumb(ref mut drag) => {
703
                // Scope the remap to the DOM this drag belongs to: a different
704
                // DOM's reconciliation must not touch our scroll container id.
705
4
                if drag.dom_id != dom_id {
706
1
                    return true;
707
3
                }
708
3
                if let Some(&new_id) = node_id_map.get(&drag.scroll_container_node) {
709
2
                    drag.scroll_container_node = new_id;
710
2
                    true
711
                } else {
712
1
                    false // scroll container removed
713
                }
714
            }
715
29
            ActiveDragType::Node(ref mut drag) => {
716
29
                if drag.dom_id != dom_id {
717
                    return true;
718
29
                }
719
29
                if let Some(&new_id) = node_id_map.get(&drag.node_id) {
720
17
                    drag.node_id = new_id;
721
17
                } else {
722
12
                    return false; // dragged node removed
723
                }
724
                // Drop target remap — both current AND previous, otherwise a
725
                // stale `previous_drop_target` keeps a pre-reconciliation NodeId
726
                // and later generates spurious DragEnter/DragLeave against a
727
                // node that no longer exists (or a different node reusing the id).
728
17
                Self::remap_drop_target(&mut drag.current_drop_target, dom_id, node_id_map);
729
17
                Self::remap_drop_target(&mut drag.previous_drop_target, dom_id, node_id_map);
730
17
                true
731
            }
732
            // WindowMove, WindowResize, and FileDrop don't reference DOM NodeIds
733
3
            ActiveDragType::WindowMove(_) | ActiveDragType::WindowResize(_) => true,
734
3
            ActiveDragType::FileDrop(ref mut drag) => {
735
3
                Self::remap_drop_target(&mut drag.drop_target, dom_id, node_id_map);
736
3
                true
737
            }
738
        }
739
44
    }
740
}
741

            
742
azul_css::impl_option!(
743
    DragContext,
744
    OptionDragContext,
745
    copy = false,
746
    [Debug, Clone, PartialEq]
747
);
748

            
749

            
750
/// Drag offset from the cursor position at drag start (logical pixels).
751
/// `dx`/`dy` are the delta from drag start to current position.
752
#[derive(Default, Debug, Copy, Clone, PartialEq, PartialOrd)]
753
#[repr(C)]
754
pub struct DragDelta {
755
    pub dx: f32,
756
    pub dy: f32,
757
}
758

            
759
impl DragDelta {
760
    #[inline]
761
12
    #[must_use] pub const fn new(dx: f32, dy: f32) -> Self {
762
12
        Self { dx, dy }
763
12
    }
764
    #[inline]
765
3
    #[must_use] pub const fn zero() -> Self {
766
3
        Self::new(0.0, 0.0)
767
3
    }
768
}
769

            
770
impl_option!(
771
    DragDelta,
772
    OptionDragDelta,
773
    [Debug, Copy, Clone, PartialEq, PartialOrd]
774
);
775

            
776
#[cfg(test)]
777
mod audit_tests {
778
    use super::*;
779
    use crate::styled_dom::NodeHierarchyItemId;
780

            
781
2
    fn node_map(from: usize, to: usize) -> alloc::collections::BTreeMap<NodeId, NodeId> {
782
2
        let mut m = alloc::collections::BTreeMap::new();
783
2
        m.insert(NodeId::new(from), NodeId::new(to));
784
2
        m
785
2
    }
786

            
787
2
    fn dnid(dom: usize, node: usize) -> DomNodeId {
788
2
        DomNodeId {
789
2
            dom: DomId { inner: dom },
790
2
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
791
2
        }
792
2
    }
793

            
794
    #[test]
795
1
    fn scrollbar_remap_scoped_to_dom() {
796
1
        let mut ctx = DragContext::scrollbar_thumb(
797
1
            DomId { inner: 0 },
798
1
            NodeId::new(3),
799
1
            ScrollbarAxis::Vertical,
800
1
            LogicalPosition::zero(),
801
            0.0, 100.0, 300.0, 100.0,
802
            1,
803
        );
804
        // Reconciling a *different* DOM (id 1) must not touch our node id.
805
1
        let ok = ctx.remap_node_ids(DomId { inner: 1 }, &node_map(3, 99));
806
1
        assert!(ok);
807
1
        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(3));
808

            
809
        // Reconciling our own DOM (id 0) remaps it.
810
1
        let ok2 = ctx.remap_node_ids(DomId { inner: 0 }, &node_map(3, 99));
811
1
        assert!(ok2);
812
1
        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(99));
813
1
    }
814

            
815
    #[test]
816
1
    fn node_drag_remaps_previous_drop_target() {
817
1
        let mut ctx = DragContext::node_drag(
818
1
            DomId { inner: 0 },
819
1
            NodeId::new(1),
820
1
            LogicalPosition::zero(),
821
1
            DragData::new(),
822
            2,
823
        );
824
1
        {
825
1
            let nd = ctx.as_node_drag_mut().unwrap();
826
1
            nd.current_drop_target = Some(dnid(0, 5)).into();
827
1
            nd.previous_drop_target = Some(dnid(0, 6)).into();
828
1
        }
829
        // Map: dragged node 1->1, drop targets 5->50, 6->60.
830
1
        let mut m = alloc::collections::BTreeMap::new();
831
1
        m.insert(NodeId::new(1), NodeId::new(1));
832
1
        m.insert(NodeId::new(5), NodeId::new(50));
833
1
        m.insert(NodeId::new(6), NodeId::new(60));
834
1
        assert!(ctx.remap_node_ids(DomId { inner: 0 }, &m));
835

            
836
1
        let nd = ctx.as_node_drag().unwrap();
837
1
        let cur = nd.current_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
838
1
        let prev = nd.previous_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
839
1
        assert_eq!(cur, NodeId::new(50));
840
1
        assert_eq!(prev, NodeId::new(60)); // previously left stale (bug)
841
1
    }
842
}
843

            
844
#[cfg(test)]
845
mod autotest_generated {
846
    use alloc::collections::BTreeMap;
847
    use alloc::string::{String, ToString};
848

            
849
    use super::*;
850
    use crate::geom::PhysicalPosition;
851
    use crate::styled_dom::NodeHierarchyItemId;
852

            
853
    // ---------------------------------------------------------------- helpers
854

            
855
    fn dom(i: usize) -> DomId {
856
        DomId { inner: i }
857
    }
858

            
859
    fn dnid(dom_idx: usize, node: usize) -> DomNodeId {
860
        DomNodeId {
861
            dom: dom(dom_idx),
862
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
863
        }
864
    }
865

            
866
    /// A drop target that points at a DOM but carries *no* node id (the `None`
867
    /// encoding of `NodeHierarchyItemId`).
868
    fn dnid_no_node(dom_idx: usize) -> DomNodeId {
869
        DomNodeId {
870
            dom: dom(dom_idx),
871
            node: NodeHierarchyItemId::from_crate_internal(None),
872
        }
873
    }
874

            
875
    fn nid_map(pairs: &[(usize, usize)]) -> BTreeMap<NodeId, NodeId> {
876
        let mut m = BTreeMap::new();
877
        for (from, to) in pairs {
878
            m.insert(NodeId::new(*from), NodeId::new(*to));
879
        }
880
        m
881
    }
882

            
883
    /// Vertical scrollbar drag anchored at (0, 0), no mouse movement yet.
884
    fn vscroll(
885
        start_scroll_offset: f32,
886
        track_length_px: f32,
887
        content_length_px: f32,
888
        viewport_length_px: f32,
889
    ) -> DragContext {
890
        DragContext::scrollbar_thumb(
891
            dom(0),
892
            NodeId::new(1),
893
            ScrollbarAxis::Vertical,
894
            LogicalPosition::zero(),
895
            start_scroll_offset,
896
            track_length_px,
897
            content_length_px,
898
            viewport_length_px,
899
            7,
900
        )
901
    }
902

            
903
    fn resize_ctx() -> DragContext {
904
        DragContext::new(
905
            ActiveDragType::WindowResize(WindowResizeDrag {
906
                edge: WindowResizeEdge::BottomRight,
907
                start_position: LogicalPosition::new(1.0, 2.0),
908
                current_position: LogicalPosition::new(1.0, 2.0),
909
                initial_width: u32::MAX,
910
                initial_height: 0,
911
            }),
912
            u64::MAX,
913
        )
914
    }
915

            
916
    // ============================================================ DragData
917
    // parser-ish surface: get_data / set_data / set_text / get_text
918

            
919
    #[test]
920
    fn dragdata_new_is_empty_and_matches_default() {
921
        let d = DragData::new();
922
        assert_eq!(d.data.len(), 0);
923
        assert!(d.data.is_empty());
924
        assert_eq!(d.effect_allowed, DragEffect::Uninitialized);
925
        assert_eq!(d, DragData::default());
926
        assert!(d.get_data("text/plain").is_none());
927
        assert!(d.get_text().is_none());
928
    }
929

            
930
    #[test]
931
    fn get_data_valid_minimal_positive_control() {
932
        let mut d = DragData::new();
933
        d.set_data("text/plain", b"hi".to_vec());
934
        assert_eq!(d.get_data("text/plain"), Some(&b"hi"[..]));
935
    }
936

            
937
    #[test]
938
    fn get_data_empty_key_on_empty_and_populated_returns_none() {
939
        let empty = DragData::new();
940
        assert!(empty.get_data("").is_none());
941

            
942
        let mut d = DragData::new();
943
        d.set_data("text/plain", b"x".to_vec());
944
        assert!(d.get_data("").is_none());
945
    }
946

            
947
    #[test]
948
    fn get_data_empty_key_is_a_real_key_when_stored() {
949
        // "" is not special-cased: it is a perfectly good (if silly) map key.
950
        let mut d = DragData::new();
951
        d.set_data("", b"empty-key".to_vec());
952
        assert_eq!(d.get_data(""), Some(&b"empty-key"[..]));
953
        assert!(d.get_data("text/plain").is_none());
954
    }
955

            
956
    #[test]
957
    fn get_data_whitespace_only_keys_return_none() {
958
        let mut d = DragData::new();
959
        d.set_data("text/plain", b"x".to_vec());
960
        for k in ["   ", "\t\n", "\r\n", "\u{a0}", "\u{2028}"] {
961
            assert!(d.get_data(k).is_none(), "whitespace key {k:?} matched");
962
        }
963
    }
964

            
965
    #[test]
966
    fn get_data_garbage_bytes_return_none_without_panicking() {
967
        let mut d = DragData::new();
968
        d.set_data("text/plain", b"x".to_vec());
969
        for k in [
970
            "\u{0}",
971
            "\u{0}\u{1}\u{2}\u{7f}",
972
            "\u{feff}",
973
            "%%%;;;///",
974
            "text/plain\u{0}",
975
            "\u{0}text/plain",
976
        ] {
977
            assert!(d.get_data(k).is_none(), "garbage key {k:?} matched");
978
        }
979
    }
980

            
981
    #[test]
982
    fn get_data_leading_trailing_junk_is_not_trimmed() {
983
        let mut d = DragData::new();
984
        d.set_data("text/plain", b"x".to_vec());
985
        // Lookup is an exact byte-for-byte match: no trimming, no tolerance.
986
        assert!(d.get_data("  text/plain  ").is_none());
987
        assert!(d.get_data("text/plain;garbage").is_none());
988
        assert!(d.get_data("text/plain ").is_none());
989
        assert!(d.get_data(" text/plain").is_none());
990
        assert_eq!(d.get_data("text/plain"), Some(&b"x"[..]));
991
    }
992

            
993
    #[test]
994
    fn get_data_is_case_sensitive() {
995
        // MIME types are case-insensitive per RFC 2045, but this map is not.
996
        let mut d = DragData::new();
997
        d.set_data("text/plain", b"x".to_vec());
998
        assert!(d.get_data("TEXT/PLAIN").is_none());
999
        assert!(d.get_data("Text/Plain").is_none());
    }
    #[test]
    fn get_data_boundary_number_strings_return_none() {
        let mut d = DragData::new();
        d.set_data("text/plain", b"x".to_vec());
        for k in [
            "0",
            "-0",
            "9223372036854775807",  // i64::MAX
            "-9223372036854775808", // i64::MIN
            "18446744073709551615", // u64::MAX
            "1e400",
            "NaN",
            "inf",
            "-inf",
            "1.7976931348623157e308",
            "5e-324",
        ] {
            assert!(d.get_data(k).is_none(), "numeric key {k:?} matched");
        }
    }
    #[test]
    fn get_data_unicode_keys_round_trip() {
        let mut d = DragData::new();
        let emoji = "application/x-\u{1F600};charset=utf-8";
        let combining = "text/e\u{0301}"; // e + combining acute
        d.set_data(emoji, b"grin".to_vec());
        d.set_data(combining, b"acute".to_vec());
        assert_eq!(d.get_data(emoji), Some(&b"grin"[..]));
        assert_eq!(d.get_data(combining), Some(&b"acute"[..]));
        // NFC-equivalent but byte-distinct key must NOT match (no normalization).
        assert!(d.get_data("text/\u{e9}").is_none());
        assert_eq!(d.data.len(), 2);
    }
    #[test]
    fn get_data_extremely_long_key_does_not_panic_or_hang() {
        let huge: String = "a".repeat(1_000_000);
        let mut d = DragData::new();
        d.set_data("text/plain", b"x".to_vec());
        // Miss against a 1M-char key.
        assert!(d.get_data(&huge).is_none());
        // Round-trip the 1M-char key itself.
        d.set_data(huge.as_str(), b"huge-key".to_vec());
        assert_eq!(d.get_data(&huge), Some(&b"huge-key"[..]));
        // A 1M-char key that differs only in the last byte must miss.
        let mut nearly = huge.clone();
        let _ = nearly.pop();
        nearly.push('b');
        assert!(d.get_data(&nearly).is_none());
    }
    #[test]
    fn get_data_deeply_nested_brackets_do_not_stack_overflow() {
        // The lookup is a linear scan, not a recursive-descent parse: 10k
        // nested brackets must be inert.
        let nested: String = "[".repeat(10_000);
        let mut d = DragData::new();
        assert!(d.get_data(&nested).is_none());
        d.set_data(nested.as_str(), b"nested".to_vec());
        assert_eq!(d.get_data(&nested), Some(&b"nested"[..]));
    }
    #[test]
    fn set_data_replaces_existing_entry_for_same_mime() {
        let mut d = DragData::new();
        d.set_data("text/plain", b"first".to_vec());
        d.set_data("text/plain", b"second".to_vec());
        assert_eq!(d.data.len(), 1, "duplicate MIME key was appended");
        assert_eq!(d.get_data("text/plain"), Some(&b"second"[..]));
    }
    #[test]
    fn set_data_empty_payload_is_some_empty_not_none() {
        let mut d = DragData::new();
        d.set_data("application/octet-stream", Vec::new());
        // Presence and emptiness are distinguishable.
        assert_eq!(d.get_data("application/octet-stream"), Some(&b""[..]));
        assert!(d.get_data("application/octet-stream").is_some());
    }
    #[test]
    fn set_data_huge_payload_round_trips() {
        let mut d = DragData::new();
        let payload = alloc::vec![0xABu8; 1 << 20]; // 1 MiB
        d.set_data("application/octet-stream", payload);
        let got = d.get_data("application/octet-stream").unwrap();
        assert_eq!(got.len(), 1 << 20);
        assert!(got.iter().all(|b| *b == 0xAB));
    }
    #[test]
    fn set_data_binary_payload_is_not_utf8_validated() {
        let mut d = DragData::new();
        d.set_data("application/octet-stream", alloc::vec![0xFF, 0x00, 0xFE]);
        assert_eq!(d.get_data("application/octet-stream"), Some(&[0xFFu8, 0x00, 0xFE][..]));
    }
    #[test]
    fn set_data_many_distinct_mime_types_all_retrievable() {
        let mut d = DragData::new();
        for i in 0..1_000usize {
            let mut key = String::from("application/x-");
            key.push_str(&i.to_string());
            d.set_data(key.as_str(), alloc::vec![(i % 251) as u8]);
        }
        assert_eq!(d.data.len(), 1_000);
        assert_eq!(d.get_data("application/x-0"), Some(&[0u8][..]));
        assert_eq!(d.get_data("application/x-999"), Some(&[(999 % 251) as u8][..]));
        assert!(d.get_data("application/x-1000").is_none());
    }
    #[test]
    fn set_text_get_text_round_trip_unicode() {
        for s in [
            "hello",
            "",
            "\u{1F600}\u{1F4A9}",
            "e\u{0301}\u{0300}\u{0308}", // stacked combining marks
            "\u{200B}zero-width",
            "line1\nline2\r\n\ttab",
            "\u{0}interior nul\u{0}",
        ] {
            let mut d = DragData::new();
            d.set_text(s);
            let got = d.get_text().expect("text/plain must be present");
            assert_eq!(got.as_str(), s, "round-trip failed for {s:?}");
        }
    }
    #[test]
    fn set_text_empty_is_some_not_none() {
        let mut d = DragData::new();
        d.set_text("");
        assert!(d.get_text().is_some());
        assert_eq!(d.get_text().unwrap().as_str(), "");
        assert_eq!(d.get_data("text/plain"), Some(&b""[..]));
    }
    #[test]
    fn set_text_huge_string_round_trips() {
        let huge: String = "\u{1F600}".repeat(100_000); // 400_000 bytes
        let mut d = DragData::new();
        d.set_text(huge.as_str());
        let got = d.get_text().unwrap();
        assert_eq!(got.as_str().len(), 400_000);
        assert_eq!(got.as_str(), huge.as_str());
        assert_eq!(d.data.len(), 1);
    }
    #[test]
    fn set_text_twice_replaces_and_does_not_grow() {
        let mut d = DragData::new();
        d.set_text("one");
        d.set_text("two");
        assert_eq!(d.data.len(), 1);
        assert_eq!(d.get_text().unwrap().as_str(), "two");
    }
    #[test]
    fn set_text_then_set_data_on_same_mime_wins() {
        let mut d = DragData::new();
        d.set_text("text");
        d.set_data("text/plain", b"raw".to_vec());
        assert_eq!(d.data.len(), 1);
        assert_eq!(d.get_text().unwrap().as_str(), "raw");
    }
    #[test]
    fn get_text_on_invalid_utf8_yields_empty_string_not_panic() {
        // Documents the `unwrap_or("")` fallback: invalid UTF-8 under
        // "text/plain" is silently reported as an EMPTY string, not None and
        // not a panic. (Lossy data: the bytes are still there via get_data.)
        let mut d = DragData::new();
        d.set_data("text/plain", alloc::vec![0xFF, 0xFE, 0x80]);
        let got = d.get_text().expect("entry exists, so Some");
        assert_eq!(got.as_str(), "");
        assert_eq!(d.get_data("text/plain"), Some(&[0xFFu8, 0xFE, 0x80][..]));
    }
    #[test]
    fn get_text_truncated_utf8_yields_empty_string() {
        let mut d = DragData::new();
        // First 3 bytes of a 4-byte emoji.
        d.set_data("text/plain", alloc::vec![0xF0, 0x9F, 0x98]);
        assert_eq!(d.get_text().unwrap().as_str(), "");
    }
    #[test]
    fn get_text_is_none_when_only_other_mimes_present() {
        let mut d = DragData::new();
        d.set_data("text/html", b"<b>x</b>".to_vec());
        assert!(d.get_text().is_none());
    }
    // ==================================================== DragContext ctors
    #[test]
    fn drag_context_new_preserves_session_id_and_is_not_cancelled() {
        for sid in [0u64, 1, u64::MAX] {
            let ctx = DragContext::new(
                ActiveDragType::WindowMove(WindowMoveDrag {
                    start_position: LogicalPosition::zero(),
                    current_position: LogicalPosition::zero(),
                    initial_window_position: WindowPosition::Uninitialized,
                }),
                sid,
            );
            assert_eq!(ctx.session_id, sid);
            assert!(!ctx.cancelled);
        }
    }
    #[test]
    fn text_selection_invariants_at_extremes() {
        let pos = LogicalPosition::new(f32::MIN, f32::MAX);
        let ctx = DragContext::text_selection(
            dom(usize::MAX),
            NodeId::new(usize::MAX),
            pos,
            u64::MAX,
        );
        let ts = ctx.as_text_selection().expect("must be a text selection");
        assert_eq!(ts.dom_id, dom(usize::MAX));
        assert_eq!(ts.anchor_ifc_node, NodeId::new(usize::MAX));
        assert!(ts.anchor_cursor.is_none());
        // start == current at construction, bit-for-bit (quantized PartialEq
        // would happily accept a saturated mismatch here, so compare raw bits).
        assert_eq!(ts.start_mouse_position.x.to_bits(), f32::MIN.to_bits());
        assert_eq!(ts.start_mouse_position.y.to_bits(), f32::MAX.to_bits());
        assert_eq!(
            ts.current_mouse_position.x.to_bits(),
            ts.start_mouse_position.x.to_bits()
        );
        assert_eq!(ctx.session_id, u64::MAX);
    }
    #[test]
    fn text_selection_with_nan_position_does_not_panic() {
        let ctx = DragContext::text_selection(
            dom(0),
            NodeId::ZERO,
            LogicalPosition::new(f32::NAN, f32::NEG_INFINITY),
            0,
        );
        let ts = ctx.as_text_selection().unwrap();
        assert!(ts.start_mouse_position.x.is_nan());
        assert!(ts.current_mouse_position.x.is_nan());
        assert_eq!(ts.start_mouse_position.y, f32::NEG_INFINITY);
    }
    #[test]
    fn scrollbar_thumb_stores_all_float_metrics_verbatim_incl_nan_inf() {
        let ctx = DragContext::scrollbar_thumb(
            dom(3),
            NodeId::new(9),
            ScrollbarAxis::Horizontal,
            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
            f32::NAN,
            f32::INFINITY,
            -0.0,
            f32::MAX,
            0,
        );
        let sb = ctx.as_scrollbar_thumb().unwrap();
        assert_eq!(sb.dom_id, dom(3));
        assert_eq!(sb.scroll_container_node, NodeId::new(9));
        assert_eq!(sb.axis, ScrollbarAxis::Horizontal);
        assert!(sb.start_scroll_offset.is_nan(), "NaN was mangled at construction");
        assert_eq!(sb.track_length_px, f32::INFINITY);
        assert!(sb.content_length_px.is_sign_negative(), "-0.0 lost its sign");
        assert_eq!(sb.content_length_px, 0.0);
        assert_eq!(sb.viewport_length_px, f32::MAX);
        assert_eq!(sb.current_mouse_position.x, f32::INFINITY);
    }
    #[test]
    fn scrollbar_thumb_all_zero_is_constructible() {
        let ctx = vscroll(0.0, 0.0, 0.0, 0.0);
        assert!(ctx.is_scrollbar_thumb());
        let sb = ctx.as_scrollbar_thumb().unwrap();
        assert_eq!(sb.start_scroll_offset, 0.0);
        assert_eq!(sb.track_length_px, 0.0);
    }
    #[test]
    fn node_drag_invariants_hold_after_construction() {
        let mut data = DragData::new();
        data.set_text("payload");
        let ctx = DragContext::node_drag(
            dom(2),
            NodeId::new(usize::MAX),
            LogicalPosition::new(-1.5, 2.5),
            data,
            u64::MAX,
        );
        let nd = ctx.as_node_drag().unwrap();
        assert_eq!(nd.dom_id, dom(2));
        assert_eq!(nd.node_id, NodeId::new(usize::MAX));
        assert_eq!(nd.start_position, nd.current_position);
        assert_eq!(nd.drag_offset, LogicalPosition::zero());
        assert!(nd.current_drop_target.into_option().is_none());
        assert!(nd.previous_drop_target.into_option().is_none());
        assert!(!nd.drop_accepted);
        assert_eq!(nd.drop_effect, DropEffect::None);
        assert_eq!(nd.drag_data.get_text().unwrap().as_str(), "payload");
    }
    #[test]
    fn node_drag_with_empty_drag_data_is_fine() {
        let ctx = DragContext::node_drag(
            dom(0),
            NodeId::ZERO,
            LogicalPosition::new(f32::NAN, f32::NAN),
            DragData::new(),
            0,
        );
        let nd = ctx.as_node_drag().unwrap();
        assert!(nd.drag_data.data.is_empty());
        assert!(nd.start_position.x.is_nan());
        assert!(nd.current_position.x.is_nan());
    }
    #[test]
    fn window_move_preserves_initial_window_position_extremes() {
        for wp in [
            WindowPosition::Uninitialized,
            WindowPosition::Initialized(PhysicalPosition {
                x: i32::MIN,
                y: i32::MAX,
            }),
            WindowPosition::Initialized(PhysicalPosition { x: 0, y: 0 }),
        ] {
            let ctx = DragContext::window_move(
                LogicalPosition::new(f32::MAX, f32::MIN),
                wp,
                u64::MAX,
            );
            let wm = ctx.as_window_move().unwrap();
            assert_eq!(wm.initial_window_position, wp);
            assert_eq!(wm.start_position.x.to_bits(), f32::MAX.to_bits());
            assert_eq!(
                wm.current_position.y.to_bits(),
                wm.start_position.y.to_bits()
            );
        }
    }
    #[test]
    fn file_drop_empty_file_list_is_allowed() {
        let ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
        let fd = ctx.as_file_drop().unwrap();
        assert_eq!(fd.files.len(), 0);
        assert!(fd.drop_target.into_option().is_none());
        assert_eq!(fd.drop_effect, DropEffect::Copy);
    }
    #[test]
    fn file_drop_unicode_and_pathological_filenames_round_trip() {
        let files = alloc::vec![
            AzString::from(""),
            AzString::from("/tmp/\u{1F4C1}/f\u{0301}ile.txt"),
            AzString::from("C:\\Windows\\..\\..\\etc\\passwd"),
            AzString::from("a".repeat(4096)),
            AzString::from("with\nnewline\tand\u{0}nul"),
        ];
        let ctx = DragContext::file_drop(files, LogicalPosition::new(1.0, 2.0), 42);
        let fd = ctx.as_file_drop().unwrap();
        assert_eq!(fd.files.len(), 5);
        assert_eq!(fd.files.as_slice()[0].as_str(), "");
        assert_eq!(fd.files.as_slice()[3].as_str().len(), 4096);
        assert_eq!(fd.files.as_slice()[4].as_str(), "with\nnewline\tand\u{0}nul");
        assert_eq!(ctx.session_id, 42);
    }
    #[test]
    fn file_drop_ten_thousand_files_does_not_hang() {
        let mut files = Vec::with_capacity(10_000);
        for i in 0..10_000usize {
            files.push(AzString::from(i.to_string()));
        }
        let ctx = DragContext::file_drop(files, LogicalPosition::zero(), 1);
        assert_eq!(ctx.as_file_drop().unwrap().files.len(), 10_000);
    }
    // ================================================ predicates / accessors
    fn one_of_each() -> [DragContext; 6] {
        [
            DragContext::text_selection(dom(0), NodeId::ZERO, LogicalPosition::zero(), 0),
            vscroll(0.0, 100.0, 200.0, 100.0),
            DragContext::node_drag(
                dom(0),
                NodeId::ZERO,
                LogicalPosition::zero(),
                DragData::new(),
                0,
            ),
            DragContext::window_move(
                LogicalPosition::zero(),
                WindowPosition::Uninitialized,
                0,
            ),
            resize_ctx(),
            DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0),
        ]
    }
    #[test]
    fn predicates_are_mutually_exclusive_across_every_variant() {
        for (i, ctx) in one_of_each().iter().enumerate() {
            let flags = [
                ctx.is_text_selection(),
                ctx.is_scrollbar_thumb(),
                ctx.is_node_drag(),
                ctx.is_window_move(),
                ctx.is_file_drop(),
            ];
            let set = flags.iter().filter(|f| **f).count();
            if i == 4 {
                // WindowResize has no predicate: every is_* must be false.
                assert_eq!(set, 0, "WindowResize matched a predicate");
            } else {
                assert_eq!(set, 1, "variant {i} matched {set} predicates, expected 1");
                assert!(flags[if i == 5 { 4 } else { i }], "wrong predicate for {i}");
            }
        }
    }
    #[test]
    fn as_accessors_return_none_for_every_non_matching_variant() {
        for (i, ctx) in one_of_each().iter().enumerate() {
            assert_eq!(ctx.as_text_selection().is_some(), i == 0);
            assert_eq!(ctx.as_scrollbar_thumb().is_some(), i == 1);
            assert_eq!(ctx.as_node_drag().is_some(), i == 2);
            assert_eq!(ctx.as_window_move().is_some(), i == 3);
            assert_eq!(ctx.as_file_drop().is_some(), i == 5);
        }
    }
    #[test]
    fn as_mut_accessors_return_none_for_every_non_matching_variant() {
        for (i, ctx) in one_of_each().iter_mut().enumerate() {
            assert_eq!(ctx.as_text_selection_mut().is_some(), i == 0);
            assert_eq!(ctx.as_scrollbar_thumb_mut().is_some(), i == 1);
            assert_eq!(ctx.as_node_drag_mut().is_some(), i == 2);
            assert_eq!(ctx.as_file_drop_mut().is_some(), i == 5);
        }
    }
    #[test]
    fn as_text_selection_mut_writes_are_visible_through_shared_getter() {
        let mut ctx =
            DragContext::text_selection(dom(0), NodeId::new(1), LogicalPosition::zero(), 0);
        {
            let ts = ctx.as_text_selection_mut().unwrap();
        }
        let ts = ctx.as_text_selection().unwrap();
    }
    #[test]
    fn as_scrollbar_thumb_mut_writes_are_visible_through_shared_getter() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
        ctx.as_scrollbar_thumb_mut().unwrap().start_scroll_offset = f32::NAN;
        assert!(ctx.as_scrollbar_thumb().unwrap().start_scroll_offset.is_nan());
    }
    #[test]
    fn as_node_drag_mut_writes_are_visible_through_shared_getter() {
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::ZERO,
            LogicalPosition::zero(),
            DragData::new(),
            0,
        );
        {
            let nd = ctx.as_node_drag_mut().unwrap();
            nd.drop_accepted = true;
            nd.drop_effect = DropEffect::Move;
            nd.current_drop_target = Some(dnid(0, 4)).into();
        }
        let nd = ctx.as_node_drag().unwrap();
        assert!(nd.drop_accepted);
        assert_eq!(nd.drop_effect, DropEffect::Move);
        assert_eq!(
            nd.current_drop_target
                .into_option()
                .unwrap()
                .node
                .into_crate_internal(),
            Some(NodeId::new(4))
        );
    }
    #[test]
    fn as_file_drop_mut_writes_are_visible_through_shared_getter() {
        let mut ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
        ctx.as_file_drop_mut().unwrap().drop_effect = DropEffect::Link;
        assert_eq!(ctx.as_file_drop().unwrap().drop_effect, DropEffect::Link);
    }
    // ============================================== update / position getters
    #[test]
    fn update_position_moves_current_for_every_variant_and_leaves_start_alone() {
        let new_pos = LogicalPosition::new(123.5, -456.25);
        for (i, mut ctx) in one_of_each().into_iter().enumerate() {
            let start_before = ctx.start_position();
            ctx.update_position(new_pos);
            assert_eq!(ctx.current_position(), new_pos, "variant {i} did not move");
            if i == 5 {
                // FileDrop has a single `position` field: start aliases current,
                // so updating the position also moves the reported start.
                assert_eq!(ctx.start_position(), new_pos);
            } else {
                assert_eq!(ctx.start_position(), start_before, "variant {i} start moved");
            }
        }
    }
    #[test]
    fn update_position_with_nan_and_inf_is_stored_verbatim() {
        for mut ctx in one_of_each() {
            ctx.update_position(LogicalPosition::new(f32::NAN, f32::INFINITY));
            let cur = ctx.current_position();
            assert!(cur.x.is_nan());
            assert_eq!(cur.y, f32::INFINITY);
            ctx.update_position(LogicalPosition::new(f32::MIN, f32::NEG_INFINITY));
            let cur = ctx.current_position();
            assert_eq!(cur.x.to_bits(), f32::MIN.to_bits());
            assert_eq!(cur.y, f32::NEG_INFINITY);
        }
    }
    #[test]
    fn update_position_is_idempotent_and_last_write_wins() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
        for i in 0..1000u32 {
            ctx.update_position(LogicalPosition::new(i as f32, -(i as f32)));
        }
        assert_eq!(ctx.current_position(), LogicalPosition::new(999.0, -999.0));
        assert_eq!(ctx.start_position(), LogicalPosition::zero());
    }
    #[test]
    fn window_resize_position_getters_work_without_a_predicate() {
        let mut ctx = resize_ctx();
        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
        assert_eq!(ctx.current_position(), LogicalPosition::new(1.0, 2.0));
        ctx.update_position(LogicalPosition::new(-3.0, -4.0));
        assert_eq!(ctx.current_position(), LogicalPosition::new(-3.0, -4.0));
        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
        // No as_window_resize() accessor exists; the others must all say None.
        assert!(ctx.as_text_selection().is_none());
        assert!(ctx.as_window_move().is_none());
    }
    // ===================================== calculate_scrollbar_scroll_offset
    #[test]
    fn scroll_offset_is_none_for_non_scrollbar_drags() {
        for (i, ctx) in one_of_each().iter().enumerate() {
            if i == 1 {
                continue;
            }
            assert!(
                ctx.calculate_scrollbar_scroll_offset().is_none(),
                "variant {i} returned Some"
            );
        }
    }
    #[test]
    fn scroll_offset_basic_vertical_half_track() {
        // track=100, content=200, viewport=100 => range=100, thumb=50,
        // scrollable_track=50. A 25px drag is half the scrollable track =>
        // half the range = 50.
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, 25.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
    }
    #[test]
    fn scroll_offset_horizontal_uses_x_and_ignores_y() {
        let mut ctx = DragContext::scrollbar_thumb(
            dom(0),
            NodeId::new(1),
            ScrollbarAxis::Horizontal,
            LogicalPosition::zero(),
            0.0,
            100.0,
            200.0,
            100.0,
            0,
        );
        // Pure vertical movement must not scroll a horizontal scrollbar.
        ctx.update_position(LogicalPosition::new(0.0, 9999.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
        ctx.update_position(LogicalPosition::new(25.0, 9999.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
    }
    #[test]
    fn scroll_offset_clamps_to_range_on_huge_and_infinite_drags() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // range = 100
        for (y, expect) in [
            (1.0e30f32, 100.0f32),
            (f32::MAX, 100.0),
            (f32::INFINITY, 100.0),
            (-1.0e30, 0.0),
            (f32::MIN, 0.0),
            (f32::NEG_INFINITY, 0.0),
        ] {
            ctx.update_position(LogicalPosition::new(0.0, y));
            assert_eq!(
                ctx.calculate_scrollbar_scroll_offset(),
                Some(expect),
                "y = {y}"
            );
        }
    }
    #[test]
    fn scroll_offset_result_always_within_range_for_finite_inputs() {
        let mut ctx = vscroll(30.0, 80.0, 500.0, 120.0);
        let range = 500.0 - 120.0;
        for y in [-1e9f32, -1.0, 0.0, 0.5, 1.0, 37.0, 1e9] {
            ctx.update_position(LogicalPosition::new(0.0, y));
            let off = ctx.calculate_scrollbar_scroll_offset().unwrap();
            assert!(
                (0.0..=range).contains(&off),
                "offset {off} escaped [0, {range}] for y = {y}"
            );
        }
    }
    #[test]
    fn scroll_offset_out_of_range_start_offset_is_clamped_back_in() {
        // Even with zero mouse movement, a bogus start offset must be clamped.
        let ctx = vscroll(9999.0, 100.0, 200.0, 100.0);
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(100.0));
        let ctx = vscroll(-9999.0, 100.0, 200.0, 100.0);
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
    }
    #[test]
    fn scroll_offset_non_scrollable_content_returns_start_offset_unchanged() {
        // content <= viewport => nothing to scroll; the (possibly out-of-range)
        // start offset is returned verbatim, WITHOUT clamping.
        let mut ctx = vscroll(7.0, 100.0, 50.0, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, 1000.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
        let ctx = vscroll(7.0, 100.0, 100.0, 100.0); // range == 0
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
        let ctx = vscroll(7.0, 100.0, 0.0, 0.0); // all zero metrics
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
    }
    #[test]
    fn scroll_offset_zero_or_negative_track_returns_start_offset() {
        for track in [0.0f32, -1.0, -1e30, f32::NEG_INFINITY] {
            let mut ctx = vscroll(7.0, track, 200.0, 100.0);
            ctx.update_position(LogicalPosition::new(0.0, 50.0));
            assert_eq!(
                ctx.calculate_scrollbar_scroll_offset(),
                Some(7.0),
                "track = {track}"
            );
        }
    }
    #[test]
    fn scroll_offset_negative_content_and_viewport_return_start_offset() {
        let mut ctx = vscroll(3.0, 100.0, -200.0, -100.0);
        ctx.update_position(LogicalPosition::new(0.0, 50.0));
        // range = -200 - (-100) = -100 <= 0 => early out.
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(3.0));
    }
    #[test]
    fn scroll_offset_nan_start_offset_propagates_nan_without_panicking() {
        let mut ctx = vscroll(f32::NAN, 100.0, 200.0, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, 10.0));
        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
        // NaN start offset is neither clamped nor rejected: it leaks through.
        assert!(off.is_nan());
    }
    #[test]
    fn scroll_offset_nan_track_length_propagates_nan_without_panicking() {
        let mut ctx = vscroll(0.0, f32::NAN, 200.0, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, 10.0));
        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
        // NaN track passes the `track_length_px <= 0.0` guard (NaN compares
        // false) and poisons the result. min/max of the clamp stay finite, so
        // no panic — just a NaN scroll offset handed to the caller.
        assert!(off.is_nan());
    }
    #[test]
    fn scroll_offset_nan_mouse_position_propagates_nan_without_panicking() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, f32::NAN));
        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
        assert!(off.is_nan());
    }
    #[test]
    fn scroll_offset_infinite_content_yields_nan_on_zero_mouse_delta() {
        // range = inf, thumb = 0, ratio = 0 => scroll_delta = 0.0 * inf = NaN.
        // Not a panic (clamp's min/max are 0.0/inf), but the returned offset is
        // NaN even though the mouse never moved.
        let ctx = vscroll(0.0, 100.0, f32::INFINITY, 100.0);
        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
        assert!(off.is_nan(), "expected the 0 * inf NaN, got {off}");
    }
    // A NaN `scrollable_range` (from a NaN, or inf-minus-inf, content/viewport
    // length) used to make `new_offset.clamp(0.0, scrollable_range)` PANIC inside
    // this getter (f32::clamp asserts min <= max, and every NaN comparison is
    // false), taking the app down on the next scrollbar drag. The guard now uses
    // `!(range > 0.0)`, so a NaN range falls back to the start offset.
    #[test]
    fn scroll_offset_nan_content_length_falls_back_without_panicking() {
        let mut ctx = vscroll(0.0, 100.0, f32::NAN, 100.0);
        ctx.update_position(LogicalPosition::new(0.0, 10.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
    }
    #[test]
    fn scroll_offset_nan_viewport_length_falls_back_without_panicking() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, f32::NAN);
        ctx.update_position(LogicalPosition::new(0.0, 10.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
    }
    #[test]
    fn scroll_offset_infinite_content_and_viewport_falls_back_without_panicking() {
        // inf - inf = NaN scrollable_range => the guard falls back to start.
        let mut ctx = vscroll(0.0, 100.0, f32::INFINITY, f32::INFINITY);
        ctx.update_position(LogicalPosition::new(0.0, 10.0));
        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
    }
    // ================================================ remap_node_ids / targets
    #[test]
    fn remap_text_selection_other_dom_is_a_no_op_and_succeeds() {
        let mut ctx =
            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
        assert!(ctx.remap_node_ids(dom(1), &nid_map(&[(5, 500)])));
        assert_eq!(ctx.as_text_selection().unwrap().anchor_ifc_node, NodeId::new(5));
    }
    #[test]
    fn remap_text_selection_missing_anchor_cancels_the_drag() {
        let mut ctx =
            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
        assert!(!ctx.remap_node_ids(dom(0), &BTreeMap::new()));
        assert!(!ctx.remap_node_ids(dom(0), &nid_map(&[(6, 7)])));
        // The anchor is left untouched when the remap fails.
        assert_eq!(ctx.as_text_selection().unwrap().anchor_ifc_node, NodeId::new(5));
    }
    #[test]
    fn remap_text_selection_follows_the_anchor_and_survives() {
        let mut ctx =
            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(5, 50), (9, 90)])));
        let ts = ctx.as_text_selection().unwrap();
        assert_eq!(ts.anchor_ifc_node, NodeId::new(50));
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(50, 51)])));
        let ts = ctx.as_text_selection().unwrap();
        assert_eq!(ts.anchor_ifc_node, NodeId::new(51));
    }
    #[test]
    fn remap_scrollbar_empty_map_cancels_the_drag() {
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // node 1, dom 0
        assert!(!ctx.remap_node_ids(dom(0), &BTreeMap::new()));
        assert_eq!(
            ctx.as_scrollbar_thumb().unwrap().scroll_container_node,
            NodeId::new(1)
        );
    }
    #[test]
    fn remap_node_drag_missing_node_cancels_and_leaves_targets_alone() {
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::new(1),
            LogicalPosition::zero(),
            DragData::new(),
            0,
        );
        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid(0, 5)).into();
        // Map covers the drop target but NOT the dragged node => cancel.
        assert!(!ctx.remap_node_ids(dom(0), &nid_map(&[(5, 50)])));
        let nd = ctx.as_node_drag().unwrap();
        assert_eq!(nd.node_id, NodeId::new(1));
        assert_eq!(
            nd.current_drop_target
                .into_option()
                .unwrap()
                .node
                .into_crate_internal(),
            Some(NodeId::new(5)),
            "targets must not be half-remapped on a cancelled drag"
        );
    }
    #[test]
    fn remap_node_drag_clears_drop_targets_that_were_removed() {
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::new(1),
            LogicalPosition::zero(),
            DragData::new(),
            0,
        );
        {
            let nd = ctx.as_node_drag_mut().unwrap();
            nd.current_drop_target = Some(dnid(0, 5)).into();
            nd.previous_drop_target = Some(dnid(0, 6)).into();
        }
        // Only the dragged node survives; both targets are gone.
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 100)])));
        let nd = ctx.as_node_drag().unwrap();
        assert_eq!(nd.node_id, NodeId::new(100));
        assert!(nd.current_drop_target.into_option().is_none());
        assert!(nd.previous_drop_target.into_option().is_none());
    }
    #[test]
    fn remap_does_not_touch_drop_targets_belonging_to_another_dom() {
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::new(1),
            LogicalPosition::zero(),
            DragData::new(),
            0,
        );
        // Drop target lives in DOM 1 (a different DOM than the drag).
        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid(1, 5)).into();
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 100), (5, 500)])));
        let nd = ctx.as_node_drag().unwrap();
        assert_eq!(nd.node_id, NodeId::new(100));
        let dt = nd.current_drop_target.into_option().unwrap();
        assert_eq!(dt.dom, dom(1));
        assert_eq!(dt.node.into_crate_internal(), Some(NodeId::new(5)));
    }
    #[test]
    fn remap_drop_target_without_a_node_id_is_left_intact() {
        // A DomNodeId whose node encodes `None` must not be cleared or panic.
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::new(1),
            LogicalPosition::zero(),
            DragData::new(),
            0,
        );
        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid_no_node(0)).into();
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 1)])));
        let dt = ctx
            .as_node_drag()
            .unwrap()
            .current_drop_target
            .into_option()
            .expect("target must survive");
        assert_eq!(dt.dom, dom(0));
        assert_eq!(dt.node.into_crate_internal(), None);
    }
    #[test]
    fn remap_drop_target_directly_handles_none_and_foreign_doms() {
        // Exercise the private helper on its own.
        let mut none_target = OptionDomNodeId::None;
        DragContext::remap_drop_target(&mut none_target, dom(0), &nid_map(&[(1, 2)]));
        assert!(none_target.into_option().is_none());
        let mut foreign: OptionDomNodeId = Some(dnid(9, 1)).into();
        DragContext::remap_drop_target(&mut foreign, dom(0), &nid_map(&[(1, 2)]));
        let dt = foreign.into_option().unwrap();
        assert_eq!(dt.dom, dom(9));
        assert_eq!(dt.node.into_crate_internal(), Some(NodeId::new(1)));
        // Empty map on a matching DOM clears the target.
        let mut matching: OptionDomNodeId = Some(dnid(0, 1)).into();
        DragContext::remap_drop_target(&mut matching, dom(0), &BTreeMap::new());
        assert!(matching.into_option().is_none());
    }
    #[test]
    fn remap_file_drop_always_survives_and_clears_stale_targets() {
        let mut ctx = DragContext::file_drop(
            alloc::vec![AzString::from("/tmp/a")],
            LogicalPosition::zero(),
            0,
        );
        // No target: nothing to do, drag survives.
        assert!(ctx.remap_node_ids(dom(0), &BTreeMap::new()));
        assert!(ctx.as_file_drop().unwrap().drop_target.into_option().is_none());
        // Target present and mapped => remapped.
        ctx.as_file_drop_mut().unwrap().drop_target = Some(dnid(0, 5)).into();
        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(5, 55)])));
        assert_eq!(
            ctx.as_file_drop()
                .unwrap()
                .drop_target
                .into_option()
                .unwrap()
                .node
                .into_crate_internal(),
            Some(NodeId::new(55))
        );
        // Target removed => cleared, but the file drop itself still survives.
        assert!(ctx.remap_node_ids(dom(0), &BTreeMap::new()));
        assert!(ctx.as_file_drop().unwrap().drop_target.into_option().is_none());
        assert_eq!(ctx.as_file_drop().unwrap().files.len(), 1);
    }
    #[test]
    fn remap_window_drags_always_succeed() {
        let mut wm = DragContext::window_move(
            LogicalPosition::zero(),
            WindowPosition::Uninitialized,
            0,
        );
        assert!(wm.remap_node_ids(dom(0), &BTreeMap::new()));
        assert!(wm.remap_node_ids(dom(usize::MAX), &nid_map(&[(1, 2)])));
        let mut wr = resize_ctx();
        assert!(wr.remap_node_ids(dom(0), &BTreeMap::new()));
        assert_eq!(wr.current_position(), LogicalPosition::new(1.0, 2.0));
    }
    #[test]
    fn remap_with_a_hundred_thousand_entries_does_not_hang() {
        let mut m = BTreeMap::new();
        for i in 0..100_000usize {
            m.insert(NodeId::new(i), NodeId::new(i + 1));
        }
        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // node 1
        assert!(ctx.remap_node_ids(dom(0), &m));
        assert_eq!(
            ctx.as_scrollbar_thumb().unwrap().scroll_container_node,
            NodeId::new(2)
        );
    }
    #[test]
    fn remap_identity_map_is_a_fixed_point() {
        let mut ctx = DragContext::node_drag(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(1.0, 2.0),
            DragData::new(),
            0,
        );
        {
            let nd = ctx.as_node_drag_mut().unwrap();
            nd.current_drop_target = Some(dnid(0, 5)).into();
            nd.previous_drop_target = Some(dnid(0, 5)).into();
        }
        let before = ctx.clone();
        let m = nid_map(&[(1, 1), (5, 5)]);
        assert!(ctx.remap_node_ids(dom(0), &m));
        assert!(ctx.remap_node_ids(dom(0), &m)); // twice, for good measure
        assert_eq!(ctx, before);
    }
    // ==================================================== DragDelta
    #[test]
    fn drag_delta_new_stores_extremes_verbatim() {
        for (dx, dy) in [
            (0.0f32, 0.0f32),
            (f32::MAX, f32::MIN),
            (f32::INFINITY, f32::NEG_INFINITY),
            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
        ] {
            let d = DragDelta::new(dx, dy);
            assert_eq!(d.dx.to_bits(), dx.to_bits());
            assert_eq!(d.dy.to_bits(), dy.to_bits());
        }
    }
    #[test]
    fn drag_delta_zero_is_the_neutral_default() {
        let z = DragDelta::zero();
        assert_eq!(z.dx, 0.0);
        assert_eq!(z.dy, 0.0);
        assert!(!z.dx.is_sign_negative(), "zero() must be +0.0, not -0.0");
        assert_eq!(z, DragDelta::default());
        assert_eq!(z, DragDelta::new(0.0, 0.0));
        // IEEE-754: -0.0 == +0.0, so a negative zero delta still equals zero().
        assert_eq!(DragDelta::new(-0.0, -0.0), z);
        // ...but the sign bit is preserved in the stored field.
        assert!(DragDelta::new(-0.0, -0.0).dx.is_sign_negative());
    }
    #[test]
    fn drag_delta_nan_is_not_equal_to_itself() {
        // Derived PartialEq on f32 => NaN != NaN. Callers must not use
        // equality to detect "no movement" on a NaN delta.
        let n = DragDelta::new(f32::NAN, f32::NAN);
        assert_ne!(n, DragDelta::new(f32::NAN, f32::NAN));
        assert_ne!(n, DragDelta::zero());
        assert!(n.dx.is_nan() && n.dy.is_nan());
        // PartialOrd is also useless on NaN: no ordering at all.
        assert!(n.partial_cmp(&DragDelta::zero()).is_none());
    }
}