Skip to main content

azul_core/
drag.rs

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
13use alloc::vec::Vec;
14
15use crate::dom::{DomId, DomNodeId, NodeId, OptionDomNodeId};
16use crate::geom::LogicalPosition;
17use crate::selection::TextCursor;
18use crate::window::WindowPosition;
19
20use 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)]
28pub 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)]
48pub 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    /// Whether we should auto-scroll (mouse near edge of scroll container)
60    pub auto_scroll_direction: AutoScrollDirection,
61    /// The scroll container that should be auto-scrolled (if any)
62    pub auto_scroll_container: Option<NodeId>,
63}
64
65/// Scrollbar thumb drag state.
66///
67/// Tracks which scrollbar is being dragged and the current offset.
68#[derive(Debug, Clone, Copy, PartialEq)]
69#[repr(C)]
70pub struct ScrollbarThumbDrag {
71    /// DOM ID that `scroll_container_node` belongs to. Used to scope
72    /// `remap_node_ids` so a reconciliation of a *different* DOM can't remap
73    /// this drag's node id against an unrelated DOM's old→new map.
74    pub dom_id: DomId,
75    /// The scroll container node being scrolled
76    pub scroll_container_node: NodeId,
77    /// Whether this is the vertical or horizontal scrollbar
78    pub axis: ScrollbarAxis,
79    /// Mouse Y position where drag started (for calculating delta)
80    pub start_mouse_position: LogicalPosition,
81    /// Scroll offset when drag started
82    pub start_scroll_offset: f32,
83    /// Current mouse position
84    pub current_mouse_position: LogicalPosition,
85    /// Track length in pixels (for calculating scroll ratio)
86    pub track_length_px: f32,
87    /// Content length in pixels (for calculating scroll ratio)
88    pub content_length_px: f32,
89    /// Viewport length in pixels (for calculating scroll ratio)
90    pub viewport_length_px: f32,
91}
92
93/// Which scrollbar axis is being dragged
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[repr(C)]
96pub enum ScrollbarAxis {
97    Vertical,
98    Horizontal,
99}
100
101/// Node drag-and-drop state.
102///
103/// Tracks a DOM node being dragged for reordering or moving.
104#[derive(Debug, Clone, PartialEq, Eq)]
105#[repr(C)]
106pub struct NodeDrag {
107    /// DOM ID of the node being dragged
108    pub dom_id: DomId,
109    /// Node ID being dragged
110    pub node_id: NodeId,
111    /// Position where drag started
112    pub start_position: LogicalPosition,
113    /// Current drag position
114    pub current_position: LogicalPosition,
115    /// Offset from node origin to click point (for correct visual positioning)
116    pub drag_offset: LogicalPosition,
117    /// Optional: DOM node currently under cursor (drop target)
118    pub current_drop_target: OptionDomNodeId,
119    /// Previous drop target (for generating DragEnter/DragLeave events)
120    pub previous_drop_target: OptionDomNodeId,
121    /// Drag data (MIME types and content)
122    pub drag_data: DragData,
123    /// Whether the current drop target has accepted the drop via `accept_drop()`
124    pub drop_accepted: bool,
125    /// Drop effect set by the drop target
126    pub drop_effect: DropEffect,
127}
128
129/// Window move drag state.
130///
131/// Tracks the window being moved via titlebar drag.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[repr(C)]
134pub struct WindowMoveDrag {
135    /// Position where window drag started (in screen coordinates)
136    pub start_position: LogicalPosition,
137    /// Current drag position
138    pub current_position: LogicalPosition,
139    /// Initial window position before drag
140    pub initial_window_position: WindowPosition,
141}
142
143/// Window resize drag state.
144///
145/// Tracks the window being resized via edge/corner drag.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147#[repr(C)]
148pub struct WindowResizeDrag {
149    /// Which edge/corner is being dragged
150    pub edge: WindowResizeEdge,
151    /// Position where resize started
152    pub start_position: LogicalPosition,
153    /// Current drag position
154    pub current_position: LogicalPosition,
155    /// Initial window size before resize
156    pub initial_width: u32,
157    /// Initial window height before resize
158    pub initial_height: u32,
159}
160
161/// Which edge or corner of the window is being resized
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163#[repr(C)]
164pub enum WindowResizeEdge {
165    Top,
166    Bottom,
167    Left,
168    Right,
169    TopLeft,
170    TopRight,
171    BottomLeft,
172    BottomRight,
173}
174
175/// File drop from OS drag state.
176///
177/// Tracks files being dragged from the operating system.
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[repr(C)]
180pub struct FileDropDrag {
181    /// Files being dragged (as string paths)
182    pub files: StringVec,
183    /// Current position of drag cursor
184    pub position: LogicalPosition,
185    /// DOM node under cursor (potential drop target)
186    pub drop_target: OptionDomNodeId,
187    /// Allowed drop effect
188    pub drop_effect: DropEffect,
189}
190
191/// Direction for auto-scrolling during drag operations
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193#[repr(C)]
194pub enum AutoScrollDirection {
195    #[default]
196    None,
197    Up,
198    Down,
199    Left,
200    Right,
201    UpLeft,
202    UpRight,
203    DownLeft,
204    DownRight,
205}
206
207/// Drop effect — the operation that will happen if the data is dropped
208/// on the current target (HTML5 `DataTransfer.dropEffect`).
209///
210/// This is a strict subset of `DragEffect`: a drop target selects one of
211/// these four outcomes, which must also be allowed by the source's
212/// `effect_allowed`.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214#[repr(C)]
215pub enum DropEffect {
216    /// No drop allowed / the drop is rejected. Default.
217    #[default]
218    None,
219    /// Drop will copy the data (source retains its copy).
220    Copy,
221    /// Drop will create a link/shortcut to the data.
222    Link,
223    /// Drop will move the data (source should remove its copy).
224    Move,
225}
226
227/// Allowed drag effects — the set of operations the drag source permits
228/// (HTML5 `DataTransfer.effectAllowed`).
229///
230/// The drop target's `DropEffect` must be a member of this set for the
231/// drop to succeed. Semantic superset of `DropEffect` that adds the
232/// HTML5 combined-permission values (`CopyLink`, `CopyMove`, `LinkMove`,
233/// `All`) and the pre-drag `Uninitialized` sentinel.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235#[repr(C)]
236pub enum DragEffect {
237    /// Allowed set has not been initialized yet (equivalent to `All` in
238    /// most user agents). Default for fresh drags.
239    #[default]
240    Uninitialized,
241    /// No drop is permitted.
242    None,
243    /// Only Copy is permitted.
244    Copy,
245    /// Copy or Link is permitted.
246    CopyLink,
247    /// Copy or Move is permitted.
248    CopyMove,
249    /// Only Link is permitted.
250    Link,
251    /// Link or Move is permitted.
252    LinkMove,
253    /// Only Move is permitted.
254    Move,
255    /// Any of Copy, Link, or Move is permitted.
256    All,
257}
258
259/// FFI-safe (`mime_type`, `data`) pair used by [`DragData`] in place of
260/// a `BTreeMap<AzString, Vec<u8>>` entry.
261#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
262#[repr(C)]
263pub struct MimeTypeData {
264    pub mime_type: AzString,
265    pub data: U8Vec,
266}
267
268impl_option!(
269    MimeTypeData,
270    OptionMimeTypeData,
271    copy = false,
272    [Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash]
273);
274
275impl_vec!(
276    MimeTypeData,
277    MimeTypeDataVec,
278    MimeTypeDataVecDestructor,
279    MimeTypeDataVecDestructorType,
280    MimeTypeDataVecSlice,
281    OptionMimeTypeData
282);
283impl_vec_mut!(MimeTypeData, MimeTypeDataVec);
284impl_vec_debug!(MimeTypeData, MimeTypeDataVec);
285impl_vec_partialord!(MimeTypeData, MimeTypeDataVec);
286impl_vec_ord!(MimeTypeData, MimeTypeDataVec);
287impl_vec_clone!(MimeTypeData, MimeTypeDataVec, MimeTypeDataVecDestructor);
288impl_vec_partialeq!(MimeTypeData, MimeTypeDataVec);
289impl_vec_eq!(MimeTypeData, MimeTypeDataVec);
290impl_vec_hash!(MimeTypeData, MimeTypeDataVec);
291
292/// Drag data (HTML5 `DataTransfer`).
293///
294/// Holds the payload(s) being transferred during a drag operation, keyed
295/// by MIME type, plus the set of operations the source allows.
296#[derive(Debug, Default, Clone, PartialEq, Eq)]
297#[repr(C)]
298pub struct DragData {
299    /// MIME type -> data mapping (vec-of-pairs for FFI compatibility).
300    ///
301    /// e.g., `"text/plain" -> "Hello World"`.
302    pub data: MimeTypeDataVec,
303    /// Set of drag operations the source permits for this drag.
304    pub effect_allowed: DragEffect,
305}
306
307impl DragData {
308    /// Create new empty drag data
309    #[must_use] pub const fn new() -> Self {
310        Self {
311            data: MimeTypeDataVec::new(),
312            effect_allowed: DragEffect::Uninitialized,
313        }
314    }
315
316    /// Set data for a MIME type. Replaces any existing entry for the
317    /// same MIME type.
318    pub fn set_data(&mut self, mime_type: impl Into<AzString>, data: Vec<u8>) {
319        let mime_type = mime_type.into();
320        let value: U8Vec = data.into();
321        if let Some(entry) = self
322            .data
323            .as_mut()
324            .iter_mut()
325            .find(|e| e.mime_type == mime_type)
326        {
327            entry.data = value;
328        } else {
329            self.data.push(MimeTypeData {
330                mime_type,
331                data: value,
332            });
333        }
334    }
335
336    /// Get data for a MIME type
337    #[must_use] pub fn get_data(&self, mime_type: &str) -> Option<&[u8]> {
338        self.data
339            .as_ref()
340            .iter()
341            .find(|e| e.mime_type.as_str() == mime_type)
342            .map(|e| e.data.as_ref())
343    }
344
345    /// Set plain text data
346    pub fn set_text(&mut self, text: impl Into<AzString>) {
347        let text_str = text.into();
348        self.set_data("text/plain", text_str.as_str().as_bytes().to_vec());
349    }
350
351    /// Get plain text data
352    #[must_use] pub fn get_text(&self) -> Option<AzString> {
353        self.get_data("text/plain")
354            .map(|bytes| AzString::from(core::str::from_utf8(bytes).unwrap_or("")))
355    }
356}
357
358/// The unified drag context.
359///
360/// This struct wraps `ActiveDragType` and provides common metadata
361/// that applies to all drag operations.
362///
363/// Note: this type is Rust-only and not exposed through the C API.
364#[derive(Debug, Clone, PartialEq)]
365pub struct DragContext {
366    /// The specific type of drag operation
367    pub drag_type: ActiveDragType,
368    /// Session ID from gesture detection (links back to `GestureManager`)
369    pub session_id: u64,
370    /// Whether the drag has been cancelled (e.g., Escape pressed)
371    pub cancelled: bool,
372}
373
374impl DragContext {
375    /// Create a new drag context
376    #[must_use] pub const fn new(drag_type: ActiveDragType, session_id: u64) -> Self {
377        Self {
378            drag_type,
379            session_id,
380            cancelled: false,
381        }
382    }
383
384    /// Create a text selection drag
385    #[must_use] pub const fn text_selection(
386        dom_id: DomId,
387        anchor_ifc_node: NodeId,
388        start_mouse_position: LogicalPosition,
389        session_id: u64,
390    ) -> Self {
391        Self::new(
392            ActiveDragType::TextSelection(TextSelectionDrag {
393                dom_id,
394                anchor_ifc_node,
395                anchor_cursor: None,
396                start_mouse_position,
397                current_mouse_position: start_mouse_position,
398                auto_scroll_direction: AutoScrollDirection::None,
399                auto_scroll_container: None,
400            }),
401            session_id,
402        )
403    }
404
405    /// Create a scrollbar thumb drag
406    #[must_use] pub const fn scrollbar_thumb(
407        dom_id: DomId,
408        scroll_container_node: NodeId,
409        axis: ScrollbarAxis,
410        start_mouse_position: LogicalPosition,
411        start_scroll_offset: f32,
412        track_length_px: f32,
413        content_length_px: f32,
414        viewport_length_px: f32,
415        session_id: u64,
416    ) -> Self {
417        Self::new(
418            ActiveDragType::ScrollbarThumb(ScrollbarThumbDrag {
419                dom_id,
420                scroll_container_node,
421                axis,
422                start_mouse_position,
423                start_scroll_offset,
424                current_mouse_position: start_mouse_position,
425                track_length_px,
426                content_length_px,
427                viewport_length_px,
428            }),
429            session_id,
430        )
431    }
432
433    /// Create a node drag
434    #[must_use] pub const fn node_drag(
435        dom_id: DomId,
436        node_id: NodeId,
437        start_position: LogicalPosition,
438        drag_data: DragData,
439        session_id: u64,
440    ) -> Self {
441        Self::new(
442            ActiveDragType::Node(NodeDrag {
443                dom_id,
444                node_id,
445                start_position,
446                current_position: start_position,
447                drag_offset: LogicalPosition::zero(),
448                current_drop_target: OptionDomNodeId::None,
449                previous_drop_target: OptionDomNodeId::None,
450                drag_data,
451                drop_accepted: false,
452                drop_effect: DropEffect::None,
453            }),
454            session_id,
455        )
456    }
457
458    /// Create a window move drag
459    #[must_use] pub const fn window_move(
460        start_position: LogicalPosition,
461        initial_window_position: WindowPosition,
462        session_id: u64,
463    ) -> Self {
464        Self::new(
465            ActiveDragType::WindowMove(WindowMoveDrag {
466                start_position,
467                current_position: start_position,
468                initial_window_position,
469            }),
470            session_id,
471        )
472    }
473
474    /// Create a file drop drag
475    #[must_use] pub fn file_drop(files: Vec<AzString>, position: LogicalPosition, session_id: u64) -> Self {
476        Self::new(
477            ActiveDragType::FileDrop(FileDropDrag {
478                files: files.into(),
479                position,
480                drop_target: OptionDomNodeId::None,
481                drop_effect: DropEffect::Copy,
482            }),
483            session_id,
484        )
485    }
486
487    /// Update the current mouse position for all drag types
488    pub const fn update_position(&mut self, position: LogicalPosition) {
489        match &mut self.drag_type {
490            ActiveDragType::TextSelection(ref mut drag) => {
491                drag.current_mouse_position = position;
492            }
493            ActiveDragType::ScrollbarThumb(ref mut drag) => {
494                drag.current_mouse_position = position;
495            }
496            ActiveDragType::Node(ref mut drag) => {
497                drag.current_position = position;
498            }
499            ActiveDragType::WindowMove(ref mut drag) => {
500                drag.current_position = position;
501            }
502            ActiveDragType::WindowResize(ref mut drag) => {
503                drag.current_position = position;
504            }
505            ActiveDragType::FileDrop(ref mut drag) => {
506                drag.position = position;
507            }
508        }
509    }
510
511    /// Get the current mouse position
512    #[must_use] pub const fn current_position(&self) -> LogicalPosition {
513        match &self.drag_type {
514            ActiveDragType::TextSelection(drag) => drag.current_mouse_position,
515            ActiveDragType::ScrollbarThumb(drag) => drag.current_mouse_position,
516            ActiveDragType::Node(drag) => drag.current_position,
517            ActiveDragType::WindowMove(drag) => drag.current_position,
518            ActiveDragType::WindowResize(drag) => drag.current_position,
519            ActiveDragType::FileDrop(drag) => drag.position,
520        }
521    }
522
523    /// Get the start position
524    #[must_use] pub const fn start_position(&self) -> LogicalPosition {
525        match &self.drag_type {
526            ActiveDragType::TextSelection(drag) => drag.start_mouse_position,
527            ActiveDragType::ScrollbarThumb(drag) => drag.start_mouse_position,
528            ActiveDragType::Node(drag) => drag.start_position,
529            ActiveDragType::WindowMove(drag) => drag.start_position,
530            ActiveDragType::WindowResize(drag) => drag.start_position,
531            ActiveDragType::FileDrop(drag) => drag.position, // No start for file drops
532        }
533    }
534
535    /// Check if this is a text selection drag
536    #[must_use] pub const fn is_text_selection(&self) -> bool {
537        matches!(self.drag_type, ActiveDragType::TextSelection(_))
538    }
539
540    /// Check if this is a scrollbar thumb drag
541    #[must_use] pub const fn is_scrollbar_thumb(&self) -> bool {
542        matches!(self.drag_type, ActiveDragType::ScrollbarThumb(_))
543    }
544
545    /// Check if this is a node drag
546    #[must_use] pub const fn is_node_drag(&self) -> bool {
547        matches!(self.drag_type, ActiveDragType::Node(_))
548    }
549
550    /// Check if this is a window move drag
551    #[must_use] pub const fn is_window_move(&self) -> bool {
552        matches!(self.drag_type, ActiveDragType::WindowMove(_))
553    }
554
555    /// Check if this is a file drop
556    #[must_use] pub const fn is_file_drop(&self) -> bool {
557        matches!(self.drag_type, ActiveDragType::FileDrop(_))
558    }
559
560    /// Get as text selection drag (if applicable)
561    #[must_use] pub const fn as_text_selection(&self) -> Option<&TextSelectionDrag> {
562        match &self.drag_type {
563            ActiveDragType::TextSelection(drag) => Some(drag),
564            _ => None,
565        }
566    }
567
568    /// Get as mutable text selection drag (if applicable)
569    pub const fn as_text_selection_mut(&mut self) -> Option<&mut TextSelectionDrag> {
570        match &mut self.drag_type {
571            ActiveDragType::TextSelection(drag) => Some(drag),
572            _ => None,
573        }
574    }
575
576    /// Get as scrollbar thumb drag (if applicable)
577    #[must_use] pub const fn as_scrollbar_thumb(&self) -> Option<&ScrollbarThumbDrag> {
578        match &self.drag_type {
579            ActiveDragType::ScrollbarThumb(drag) => Some(drag),
580            _ => None,
581        }
582    }
583
584    /// Get as mutable scrollbar thumb drag (if applicable)
585    pub const fn as_scrollbar_thumb_mut(&mut self) -> Option<&mut ScrollbarThumbDrag> {
586        match &mut self.drag_type {
587            ActiveDragType::ScrollbarThumb(drag) => Some(drag),
588            _ => None,
589        }
590    }
591
592    /// Get as node drag (if applicable)
593    #[must_use] pub const fn as_node_drag(&self) -> Option<&NodeDrag> {
594        match &self.drag_type {
595            ActiveDragType::Node(drag) => Some(drag),
596            _ => None,
597        }
598    }
599
600    /// Get as mutable node drag (if applicable)
601    pub const fn as_node_drag_mut(&mut self) -> Option<&mut NodeDrag> {
602        match &mut self.drag_type {
603            ActiveDragType::Node(drag) => Some(drag),
604            _ => None,
605        }
606    }
607
608    /// Get as window move drag (if applicable)
609    #[must_use] pub const fn as_window_move(&self) -> Option<&WindowMoveDrag> {
610        match &self.drag_type {
611            ActiveDragType::WindowMove(drag) => Some(drag),
612            _ => None,
613        }
614    }
615
616    /// Get as file drop (if applicable)
617    #[must_use] pub const fn as_file_drop(&self) -> Option<&FileDropDrag> {
618        match &self.drag_type {
619            ActiveDragType::FileDrop(drag) => Some(drag),
620            _ => None,
621        }
622    }
623
624    /// Get as mutable file drop (if applicable)
625    pub const fn as_file_drop_mut(&mut self) -> Option<&mut FileDropDrag> {
626        match &mut self.drag_type {
627            ActiveDragType::FileDrop(drag) => Some(drag),
628            _ => None,
629        }
630    }
631
632    /// Calculate scroll delta for scrollbar thumb drag
633    ///
634    /// Returns the new scroll offset based on current mouse position.
635    #[must_use] pub fn calculate_scrollbar_scroll_offset(&self) -> Option<f32> {
636        let drag = self.as_scrollbar_thumb()?;
637        
638        // Calculate mouse delta along the drag axis
639        let mouse_delta = match drag.axis {
640            ScrollbarAxis::Vertical => {
641                drag.current_mouse_position.y - drag.start_mouse_position.y
642            }
643            ScrollbarAxis::Horizontal => {
644                drag.current_mouse_position.x - drag.start_mouse_position.x
645            }
646        };
647
648        // Calculate the scrollable range
649        let scrollable_range = drag.content_length_px - drag.viewport_length_px;
650        // The explicit `is_nan()` (equivalent to the old `!(x > 0.0)`) catches a NaN
651        // scrollable_range — from a NaN, or inf-minus-inf, content/viewport length —
652        // so it never reaches the `clamp(0.0, scrollable_range)` below, whose
653        // f32::clamp would panic (it asserts min <= max, and NaN fails every compare).
654        if scrollable_range <= 0.0 || scrollable_range.is_nan() || drag.track_length_px <= 0.0 {
655            return Some(drag.start_scroll_offset);
656        }
657
658        // Calculate thumb length (proportional to viewport/content ratio)
659        let thumb_length = (drag.viewport_length_px / drag.content_length_px) * drag.track_length_px;
660        let scrollable_track = drag.track_length_px - thumb_length;
661
662        if scrollable_track <= 0.0 {
663            return Some(drag.start_scroll_offset);
664        }
665
666        // Convert mouse delta to scroll delta
667        let scroll_ratio = mouse_delta / scrollable_track;
668        let scroll_delta = scroll_ratio * scrollable_range;
669
670        // Calculate new scroll offset
671        let new_offset = drag.start_scroll_offset + scroll_delta;
672
673        // Clamp to valid range
674        Some(new_offset.clamp(0.0, scrollable_range))
675    }
676
677    /// Remap a drop target's `NodeId` using the old→new mapping.
678    /// Clears the target if the old `NodeId` was removed.
679    fn remap_drop_target(
680        target: &mut OptionDomNodeId,
681        dom_id: DomId,
682        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
683    ) {
684        let dt = match target.into_option() {
685            Some(dt) if dt.dom == dom_id => dt,
686            _ => return,
687        };
688        let Some(old_nid) = dt.node.into_crate_internal() else {
689            return;
690        };
691        if let Some(&new_nid) = node_id_map.get(&old_nid) {
692            *target = Some(DomNodeId {
693                dom: dom_id,
694                node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(new_nid)),
695            }).into();
696        } else {
697            *target = OptionDomNodeId::None;
698        }
699    }
700
701    /// Remap `NodeIds` stored in this drag context after DOM reconciliation.
702    ///
703    /// When the DOM is regenerated during an active drag, `NodeIds` can change.
704    /// This updates all stored `NodeIds` using the old→new mapping.
705    /// Returns `false` if a critical `NodeId` was removed (drag should be cancelled).
706    pub fn remap_node_ids(
707        &mut self,
708        dom_id: DomId,
709        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
710    ) -> bool {
711        match &mut self.drag_type {
712            ActiveDragType::TextSelection(ref mut drag) => {
713                if drag.dom_id != dom_id {
714                    return true;
715                }
716                if let Some(&new_id) = node_id_map.get(&drag.anchor_ifc_node) {
717                    drag.anchor_ifc_node = new_id;
718                } else {
719                    return false; // anchor node removed
720                }
721                if let Some(ref mut container) = drag.auto_scroll_container {
722                    if let Some(&new_id) = node_id_map.get(container) {
723                        *container = new_id;
724                    } else {
725                        drag.auto_scroll_container = None;
726                    }
727                }
728                true
729            }
730            ActiveDragType::ScrollbarThumb(ref mut drag) => {
731                // Scope the remap to the DOM this drag belongs to: a different
732                // DOM's reconciliation must not touch our scroll container id.
733                if drag.dom_id != dom_id {
734                    return true;
735                }
736                if let Some(&new_id) = node_id_map.get(&drag.scroll_container_node) {
737                    drag.scroll_container_node = new_id;
738                    true
739                } else {
740                    false // scroll container removed
741                }
742            }
743            ActiveDragType::Node(ref mut drag) => {
744                if drag.dom_id != dom_id {
745                    return true;
746                }
747                if let Some(&new_id) = node_id_map.get(&drag.node_id) {
748                    drag.node_id = new_id;
749                } else {
750                    return false; // dragged node removed
751                }
752                // Drop target remap — both current AND previous, otherwise a
753                // stale `previous_drop_target` keeps a pre-reconciliation NodeId
754                // and later generates spurious DragEnter/DragLeave against a
755                // node that no longer exists (or a different node reusing the id).
756                Self::remap_drop_target(&mut drag.current_drop_target, dom_id, node_id_map);
757                Self::remap_drop_target(&mut drag.previous_drop_target, dom_id, node_id_map);
758                true
759            }
760            // WindowMove, WindowResize, and FileDrop don't reference DOM NodeIds
761            ActiveDragType::WindowMove(_) | ActiveDragType::WindowResize(_) => true,
762            ActiveDragType::FileDrop(ref mut drag) => {
763                Self::remap_drop_target(&mut drag.drop_target, dom_id, node_id_map);
764                true
765            }
766        }
767    }
768}
769
770azul_css::impl_option!(
771    DragContext,
772    OptionDragContext,
773    copy = false,
774    [Debug, Clone, PartialEq]
775);
776
777
778/// Drag offset from the cursor position at drag start (logical pixels).
779/// `dx`/`dy` are the delta from drag start to current position.
780#[derive(Default, Debug, Copy, Clone, PartialEq, PartialOrd)]
781#[repr(C)]
782pub struct DragDelta {
783    pub dx: f32,
784    pub dy: f32,
785}
786
787impl DragDelta {
788    #[inline]
789    #[must_use] pub const fn new(dx: f32, dy: f32) -> Self {
790        Self { dx, dy }
791    }
792    #[inline]
793    #[must_use] pub const fn zero() -> Self {
794        Self::new(0.0, 0.0)
795    }
796}
797
798impl_option!(
799    DragDelta,
800    OptionDragDelta,
801    [Debug, Copy, Clone, PartialEq, PartialOrd]
802);
803
804#[cfg(test)]
805mod audit_tests {
806    use super::*;
807    use crate::styled_dom::NodeHierarchyItemId;
808
809    fn node_map(from: usize, to: usize) -> alloc::collections::BTreeMap<NodeId, NodeId> {
810        let mut m = alloc::collections::BTreeMap::new();
811        m.insert(NodeId::new(from), NodeId::new(to));
812        m
813    }
814
815    fn dnid(dom: usize, node: usize) -> DomNodeId {
816        DomNodeId {
817            dom: DomId { inner: dom },
818            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
819        }
820    }
821
822    #[test]
823    fn scrollbar_remap_scoped_to_dom() {
824        let mut ctx = DragContext::scrollbar_thumb(
825            DomId { inner: 0 },
826            NodeId::new(3),
827            ScrollbarAxis::Vertical,
828            LogicalPosition::zero(),
829            0.0, 100.0, 300.0, 100.0,
830            1,
831        );
832        // Reconciling a *different* DOM (id 1) must not touch our node id.
833        let ok = ctx.remap_node_ids(DomId { inner: 1 }, &node_map(3, 99));
834        assert!(ok);
835        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(3));
836
837        // Reconciling our own DOM (id 0) remaps it.
838        let ok2 = ctx.remap_node_ids(DomId { inner: 0 }, &node_map(3, 99));
839        assert!(ok2);
840        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(99));
841    }
842
843    #[test]
844    fn node_drag_remaps_previous_drop_target() {
845        let mut ctx = DragContext::node_drag(
846            DomId { inner: 0 },
847            NodeId::new(1),
848            LogicalPosition::zero(),
849            DragData::new(),
850            2,
851        );
852        {
853            let nd = ctx.as_node_drag_mut().unwrap();
854            nd.current_drop_target = Some(dnid(0, 5)).into();
855            nd.previous_drop_target = Some(dnid(0, 6)).into();
856        }
857        // Map: dragged node 1->1, drop targets 5->50, 6->60.
858        let mut m = alloc::collections::BTreeMap::new();
859        m.insert(NodeId::new(1), NodeId::new(1));
860        m.insert(NodeId::new(5), NodeId::new(50));
861        m.insert(NodeId::new(6), NodeId::new(60));
862        assert!(ctx.remap_node_ids(DomId { inner: 0 }, &m));
863
864        let nd = ctx.as_node_drag().unwrap();
865        let cur = nd.current_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
866        let prev = nd.previous_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
867        assert_eq!(cur, NodeId::new(50));
868        assert_eq!(prev, NodeId::new(60)); // previously left stale (bug)
869    }
870}
871
872#[cfg(test)]
873mod autotest_generated {
874    use alloc::collections::BTreeMap;
875    use alloc::string::{String, ToString};
876
877    use super::*;
878    use crate::geom::PhysicalPosition;
879    use crate::styled_dom::NodeHierarchyItemId;
880
881    // ---------------------------------------------------------------- helpers
882
883    fn dom(i: usize) -> DomId {
884        DomId { inner: i }
885    }
886
887    fn dnid(dom_idx: usize, node: usize) -> DomNodeId {
888        DomNodeId {
889            dom: dom(dom_idx),
890            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
891        }
892    }
893
894    /// A drop target that points at a DOM but carries *no* node id (the `None`
895    /// encoding of `NodeHierarchyItemId`).
896    fn dnid_no_node(dom_idx: usize) -> DomNodeId {
897        DomNodeId {
898            dom: dom(dom_idx),
899            node: NodeHierarchyItemId::from_crate_internal(None),
900        }
901    }
902
903    fn nid_map(pairs: &[(usize, usize)]) -> BTreeMap<NodeId, NodeId> {
904        let mut m = BTreeMap::new();
905        for (from, to) in pairs {
906            m.insert(NodeId::new(*from), NodeId::new(*to));
907        }
908        m
909    }
910
911    /// Vertical scrollbar drag anchored at (0, 0), no mouse movement yet.
912    fn vscroll(
913        start_scroll_offset: f32,
914        track_length_px: f32,
915        content_length_px: f32,
916        viewport_length_px: f32,
917    ) -> DragContext {
918        DragContext::scrollbar_thumb(
919            dom(0),
920            NodeId::new(1),
921            ScrollbarAxis::Vertical,
922            LogicalPosition::zero(),
923            start_scroll_offset,
924            track_length_px,
925            content_length_px,
926            viewport_length_px,
927            7,
928        )
929    }
930
931    fn resize_ctx() -> DragContext {
932        DragContext::new(
933            ActiveDragType::WindowResize(WindowResizeDrag {
934                edge: WindowResizeEdge::BottomRight,
935                start_position: LogicalPosition::new(1.0, 2.0),
936                current_position: LogicalPosition::new(1.0, 2.0),
937                initial_width: u32::MAX,
938                initial_height: 0,
939            }),
940            u64::MAX,
941        )
942    }
943
944    // ============================================================ DragData
945    // parser-ish surface: get_data / set_data / set_text / get_text
946
947    #[test]
948    fn dragdata_new_is_empty_and_matches_default() {
949        let d = DragData::new();
950        assert_eq!(d.data.len(), 0);
951        assert!(d.data.is_empty());
952        assert_eq!(d.effect_allowed, DragEffect::Uninitialized);
953        assert_eq!(d, DragData::default());
954        assert!(d.get_data("text/plain").is_none());
955        assert!(d.get_text().is_none());
956    }
957
958    #[test]
959    fn get_data_valid_minimal_positive_control() {
960        let mut d = DragData::new();
961        d.set_data("text/plain", b"hi".to_vec());
962        assert_eq!(d.get_data("text/plain"), Some(&b"hi"[..]));
963    }
964
965    #[test]
966    fn get_data_empty_key_on_empty_and_populated_returns_none() {
967        let empty = DragData::new();
968        assert!(empty.get_data("").is_none());
969
970        let mut d = DragData::new();
971        d.set_data("text/plain", b"x".to_vec());
972        assert!(d.get_data("").is_none());
973    }
974
975    #[test]
976    fn get_data_empty_key_is_a_real_key_when_stored() {
977        // "" is not special-cased: it is a perfectly good (if silly) map key.
978        let mut d = DragData::new();
979        d.set_data("", b"empty-key".to_vec());
980        assert_eq!(d.get_data(""), Some(&b"empty-key"[..]));
981        assert!(d.get_data("text/plain").is_none());
982    }
983
984    #[test]
985    fn get_data_whitespace_only_keys_return_none() {
986        let mut d = DragData::new();
987        d.set_data("text/plain", b"x".to_vec());
988        for k in ["   ", "\t\n", "\r\n", "\u{a0}", "\u{2028}"] {
989            assert!(d.get_data(k).is_none(), "whitespace key {k:?} matched");
990        }
991    }
992
993    #[test]
994    fn get_data_garbage_bytes_return_none_without_panicking() {
995        let mut d = DragData::new();
996        d.set_data("text/plain", b"x".to_vec());
997        for k in [
998            "\u{0}",
999            "\u{0}\u{1}\u{2}\u{7f}",
1000            "\u{feff}",
1001            "%%%;;;///",
1002            "text/plain\u{0}",
1003            "\u{0}text/plain",
1004        ] {
1005            assert!(d.get_data(k).is_none(), "garbage key {k:?} matched");
1006        }
1007    }
1008
1009    #[test]
1010    fn get_data_leading_trailing_junk_is_not_trimmed() {
1011        let mut d = DragData::new();
1012        d.set_data("text/plain", b"x".to_vec());
1013        // Lookup is an exact byte-for-byte match: no trimming, no tolerance.
1014        assert!(d.get_data("  text/plain  ").is_none());
1015        assert!(d.get_data("text/plain;garbage").is_none());
1016        assert!(d.get_data("text/plain ").is_none());
1017        assert!(d.get_data(" text/plain").is_none());
1018        assert_eq!(d.get_data("text/plain"), Some(&b"x"[..]));
1019    }
1020
1021    #[test]
1022    fn get_data_is_case_sensitive() {
1023        // MIME types are case-insensitive per RFC 2045, but this map is not.
1024        let mut d = DragData::new();
1025        d.set_data("text/plain", b"x".to_vec());
1026        assert!(d.get_data("TEXT/PLAIN").is_none());
1027        assert!(d.get_data("Text/Plain").is_none());
1028    }
1029
1030    #[test]
1031    fn get_data_boundary_number_strings_return_none() {
1032        let mut d = DragData::new();
1033        d.set_data("text/plain", b"x".to_vec());
1034        for k in [
1035            "0",
1036            "-0",
1037            "9223372036854775807",  // i64::MAX
1038            "-9223372036854775808", // i64::MIN
1039            "18446744073709551615", // u64::MAX
1040            "1e400",
1041            "NaN",
1042            "inf",
1043            "-inf",
1044            "1.7976931348623157e308",
1045            "5e-324",
1046        ] {
1047            assert!(d.get_data(k).is_none(), "numeric key {k:?} matched");
1048        }
1049    }
1050
1051    #[test]
1052    fn get_data_unicode_keys_round_trip() {
1053        let mut d = DragData::new();
1054        let emoji = "application/x-\u{1F600};charset=utf-8";
1055        let combining = "text/e\u{0301}"; // e + combining acute
1056        d.set_data(emoji, b"grin".to_vec());
1057        d.set_data(combining, b"acute".to_vec());
1058
1059        assert_eq!(d.get_data(emoji), Some(&b"grin"[..]));
1060        assert_eq!(d.get_data(combining), Some(&b"acute"[..]));
1061        // NFC-equivalent but byte-distinct key must NOT match (no normalization).
1062        assert!(d.get_data("text/\u{e9}").is_none());
1063        assert_eq!(d.data.len(), 2);
1064    }
1065
1066    #[test]
1067    fn get_data_extremely_long_key_does_not_panic_or_hang() {
1068        let huge: String = "a".repeat(1_000_000);
1069        let mut d = DragData::new();
1070        d.set_data("text/plain", b"x".to_vec());
1071        // Miss against a 1M-char key.
1072        assert!(d.get_data(&huge).is_none());
1073        // Round-trip the 1M-char key itself.
1074        d.set_data(huge.as_str(), b"huge-key".to_vec());
1075        assert_eq!(d.get_data(&huge), Some(&b"huge-key"[..]));
1076        // A 1M-char key that differs only in the last byte must miss.
1077        let mut nearly = huge.clone();
1078        let _ = nearly.pop();
1079        nearly.push('b');
1080        assert!(d.get_data(&nearly).is_none());
1081    }
1082
1083    #[test]
1084    fn get_data_deeply_nested_brackets_do_not_stack_overflow() {
1085        // The lookup is a linear scan, not a recursive-descent parse: 10k
1086        // nested brackets must be inert.
1087        let nested: String = "[".repeat(10_000);
1088        let mut d = DragData::new();
1089        assert!(d.get_data(&nested).is_none());
1090        d.set_data(nested.as_str(), b"nested".to_vec());
1091        assert_eq!(d.get_data(&nested), Some(&b"nested"[..]));
1092    }
1093
1094    #[test]
1095    fn set_data_replaces_existing_entry_for_same_mime() {
1096        let mut d = DragData::new();
1097        d.set_data("text/plain", b"first".to_vec());
1098        d.set_data("text/plain", b"second".to_vec());
1099        assert_eq!(d.data.len(), 1, "duplicate MIME key was appended");
1100        assert_eq!(d.get_data("text/plain"), Some(&b"second"[..]));
1101    }
1102
1103    #[test]
1104    fn set_data_empty_payload_is_some_empty_not_none() {
1105        let mut d = DragData::new();
1106        d.set_data("application/octet-stream", Vec::new());
1107        // Presence and emptiness are distinguishable.
1108        assert_eq!(d.get_data("application/octet-stream"), Some(&b""[..]));
1109        assert!(d.get_data("application/octet-stream").is_some());
1110    }
1111
1112    #[test]
1113    fn set_data_huge_payload_round_trips() {
1114        let mut d = DragData::new();
1115        let payload = alloc::vec![0xABu8; 1 << 20]; // 1 MiB
1116        d.set_data("application/octet-stream", payload);
1117        let got = d.get_data("application/octet-stream").unwrap();
1118        assert_eq!(got.len(), 1 << 20);
1119        assert!(got.iter().all(|b| *b == 0xAB));
1120    }
1121
1122    #[test]
1123    fn set_data_binary_payload_is_not_utf8_validated() {
1124        let mut d = DragData::new();
1125        d.set_data("application/octet-stream", alloc::vec![0xFF, 0x00, 0xFE]);
1126        assert_eq!(d.get_data("application/octet-stream"), Some(&[0xFFu8, 0x00, 0xFE][..]));
1127    }
1128
1129    #[test]
1130    fn set_data_many_distinct_mime_types_all_retrievable() {
1131        let mut d = DragData::new();
1132        for i in 0..1_000usize {
1133            let mut key = String::from("application/x-");
1134            key.push_str(&i.to_string());
1135            d.set_data(key.as_str(), alloc::vec![(i % 251) as u8]);
1136        }
1137        assert_eq!(d.data.len(), 1_000);
1138        assert_eq!(d.get_data("application/x-0"), Some(&[0u8][..]));
1139        assert_eq!(d.get_data("application/x-999"), Some(&[(999 % 251) as u8][..]));
1140        assert!(d.get_data("application/x-1000").is_none());
1141    }
1142
1143    #[test]
1144    fn set_text_get_text_round_trip_unicode() {
1145        for s in [
1146            "hello",
1147            "",
1148            "\u{1F600}\u{1F4A9}",
1149            "e\u{0301}\u{0300}\u{0308}", // stacked combining marks
1150            "\u{200B}zero-width",
1151            "line1\nline2\r\n\ttab",
1152            "\u{0}interior nul\u{0}",
1153        ] {
1154            let mut d = DragData::new();
1155            d.set_text(s);
1156            let got = d.get_text().expect("text/plain must be present");
1157            assert_eq!(got.as_str(), s, "round-trip failed for {s:?}");
1158        }
1159    }
1160
1161    #[test]
1162    fn set_text_empty_is_some_not_none() {
1163        let mut d = DragData::new();
1164        d.set_text("");
1165        assert!(d.get_text().is_some());
1166        assert_eq!(d.get_text().unwrap().as_str(), "");
1167        assert_eq!(d.get_data("text/plain"), Some(&b""[..]));
1168    }
1169
1170    #[test]
1171    fn set_text_huge_string_round_trips() {
1172        let huge: String = "\u{1F600}".repeat(100_000); // 400_000 bytes
1173        let mut d = DragData::new();
1174        d.set_text(huge.as_str());
1175        let got = d.get_text().unwrap();
1176        assert_eq!(got.as_str().len(), 400_000);
1177        assert_eq!(got.as_str(), huge.as_str());
1178        assert_eq!(d.data.len(), 1);
1179    }
1180
1181    #[test]
1182    fn set_text_twice_replaces_and_does_not_grow() {
1183        let mut d = DragData::new();
1184        d.set_text("one");
1185        d.set_text("two");
1186        assert_eq!(d.data.len(), 1);
1187        assert_eq!(d.get_text().unwrap().as_str(), "two");
1188    }
1189
1190    #[test]
1191    fn set_text_then_set_data_on_same_mime_wins() {
1192        let mut d = DragData::new();
1193        d.set_text("text");
1194        d.set_data("text/plain", b"raw".to_vec());
1195        assert_eq!(d.data.len(), 1);
1196        assert_eq!(d.get_text().unwrap().as_str(), "raw");
1197    }
1198
1199    #[test]
1200    fn get_text_on_invalid_utf8_yields_empty_string_not_panic() {
1201        // Documents the `unwrap_or("")` fallback: invalid UTF-8 under
1202        // "text/plain" is silently reported as an EMPTY string, not None and
1203        // not a panic. (Lossy data: the bytes are still there via get_data.)
1204        let mut d = DragData::new();
1205        d.set_data("text/plain", alloc::vec![0xFF, 0xFE, 0x80]);
1206        let got = d.get_text().expect("entry exists, so Some");
1207        assert_eq!(got.as_str(), "");
1208        assert_eq!(d.get_data("text/plain"), Some(&[0xFFu8, 0xFE, 0x80][..]));
1209    }
1210
1211    #[test]
1212    fn get_text_truncated_utf8_yields_empty_string() {
1213        let mut d = DragData::new();
1214        // First 3 bytes of a 4-byte emoji.
1215        d.set_data("text/plain", alloc::vec![0xF0, 0x9F, 0x98]);
1216        assert_eq!(d.get_text().unwrap().as_str(), "");
1217    }
1218
1219    #[test]
1220    fn get_text_is_none_when_only_other_mimes_present() {
1221        let mut d = DragData::new();
1222        d.set_data("text/html", b"<b>x</b>".to_vec());
1223        assert!(d.get_text().is_none());
1224    }
1225
1226    // ==================================================== DragContext ctors
1227
1228    #[test]
1229    fn drag_context_new_preserves_session_id_and_is_not_cancelled() {
1230        for sid in [0u64, 1, u64::MAX] {
1231            let ctx = DragContext::new(
1232                ActiveDragType::WindowMove(WindowMoveDrag {
1233                    start_position: LogicalPosition::zero(),
1234                    current_position: LogicalPosition::zero(),
1235                    initial_window_position: WindowPosition::Uninitialized,
1236                }),
1237                sid,
1238            );
1239            assert_eq!(ctx.session_id, sid);
1240            assert!(!ctx.cancelled);
1241        }
1242    }
1243
1244    #[test]
1245    fn text_selection_invariants_at_extremes() {
1246        let pos = LogicalPosition::new(f32::MIN, f32::MAX);
1247        let ctx = DragContext::text_selection(
1248            dom(usize::MAX),
1249            NodeId::new(usize::MAX),
1250            pos,
1251            u64::MAX,
1252        );
1253        let ts = ctx.as_text_selection().expect("must be a text selection");
1254        assert_eq!(ts.dom_id, dom(usize::MAX));
1255        assert_eq!(ts.anchor_ifc_node, NodeId::new(usize::MAX));
1256        assert!(ts.anchor_cursor.is_none());
1257        assert!(ts.auto_scroll_container.is_none());
1258        assert_eq!(ts.auto_scroll_direction, AutoScrollDirection::None);
1259        // start == current at construction, bit-for-bit (quantized PartialEq
1260        // would happily accept a saturated mismatch here, so compare raw bits).
1261        assert_eq!(ts.start_mouse_position.x.to_bits(), f32::MIN.to_bits());
1262        assert_eq!(ts.start_mouse_position.y.to_bits(), f32::MAX.to_bits());
1263        assert_eq!(
1264            ts.current_mouse_position.x.to_bits(),
1265            ts.start_mouse_position.x.to_bits()
1266        );
1267        assert_eq!(ctx.session_id, u64::MAX);
1268    }
1269
1270    #[test]
1271    fn text_selection_with_nan_position_does_not_panic() {
1272        let ctx = DragContext::text_selection(
1273            dom(0),
1274            NodeId::ZERO,
1275            LogicalPosition::new(f32::NAN, f32::NEG_INFINITY),
1276            0,
1277        );
1278        let ts = ctx.as_text_selection().unwrap();
1279        assert!(ts.start_mouse_position.x.is_nan());
1280        assert!(ts.current_mouse_position.x.is_nan());
1281        assert_eq!(ts.start_mouse_position.y, f32::NEG_INFINITY);
1282    }
1283
1284    #[test]
1285    fn scrollbar_thumb_stores_all_float_metrics_verbatim_incl_nan_inf() {
1286        let ctx = DragContext::scrollbar_thumb(
1287            dom(3),
1288            NodeId::new(9),
1289            ScrollbarAxis::Horizontal,
1290            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
1291            f32::NAN,
1292            f32::INFINITY,
1293            -0.0,
1294            f32::MAX,
1295            0,
1296        );
1297        let sb = ctx.as_scrollbar_thumb().unwrap();
1298        assert_eq!(sb.dom_id, dom(3));
1299        assert_eq!(sb.scroll_container_node, NodeId::new(9));
1300        assert_eq!(sb.axis, ScrollbarAxis::Horizontal);
1301        assert!(sb.start_scroll_offset.is_nan(), "NaN was mangled at construction");
1302        assert_eq!(sb.track_length_px, f32::INFINITY);
1303        assert!(sb.content_length_px.is_sign_negative(), "-0.0 lost its sign");
1304        assert_eq!(sb.content_length_px, 0.0);
1305        assert_eq!(sb.viewport_length_px, f32::MAX);
1306        assert_eq!(sb.current_mouse_position.x, f32::INFINITY);
1307    }
1308
1309    #[test]
1310    fn scrollbar_thumb_all_zero_is_constructible() {
1311        let ctx = vscroll(0.0, 0.0, 0.0, 0.0);
1312        assert!(ctx.is_scrollbar_thumb());
1313        let sb = ctx.as_scrollbar_thumb().unwrap();
1314        assert_eq!(sb.start_scroll_offset, 0.0);
1315        assert_eq!(sb.track_length_px, 0.0);
1316    }
1317
1318    #[test]
1319    fn node_drag_invariants_hold_after_construction() {
1320        let mut data = DragData::new();
1321        data.set_text("payload");
1322        let ctx = DragContext::node_drag(
1323            dom(2),
1324            NodeId::new(usize::MAX),
1325            LogicalPosition::new(-1.5, 2.5),
1326            data,
1327            u64::MAX,
1328        );
1329        let nd = ctx.as_node_drag().unwrap();
1330        assert_eq!(nd.dom_id, dom(2));
1331        assert_eq!(nd.node_id, NodeId::new(usize::MAX));
1332        assert_eq!(nd.start_position, nd.current_position);
1333        assert_eq!(nd.drag_offset, LogicalPosition::zero());
1334        assert!(nd.current_drop_target.into_option().is_none());
1335        assert!(nd.previous_drop_target.into_option().is_none());
1336        assert!(!nd.drop_accepted);
1337        assert_eq!(nd.drop_effect, DropEffect::None);
1338        assert_eq!(nd.drag_data.get_text().unwrap().as_str(), "payload");
1339    }
1340
1341    #[test]
1342    fn node_drag_with_empty_drag_data_is_fine() {
1343        let ctx = DragContext::node_drag(
1344            dom(0),
1345            NodeId::ZERO,
1346            LogicalPosition::new(f32::NAN, f32::NAN),
1347            DragData::new(),
1348            0,
1349        );
1350        let nd = ctx.as_node_drag().unwrap();
1351        assert!(nd.drag_data.data.is_empty());
1352        assert!(nd.start_position.x.is_nan());
1353        assert!(nd.current_position.x.is_nan());
1354    }
1355
1356    #[test]
1357    fn window_move_preserves_initial_window_position_extremes() {
1358        for wp in [
1359            WindowPosition::Uninitialized,
1360            WindowPosition::Initialized(PhysicalPosition {
1361                x: i32::MIN,
1362                y: i32::MAX,
1363            }),
1364            WindowPosition::Initialized(PhysicalPosition { x: 0, y: 0 }),
1365        ] {
1366            let ctx = DragContext::window_move(
1367                LogicalPosition::new(f32::MAX, f32::MIN),
1368                wp,
1369                u64::MAX,
1370            );
1371            let wm = ctx.as_window_move().unwrap();
1372            assert_eq!(wm.initial_window_position, wp);
1373            assert_eq!(wm.start_position.x.to_bits(), f32::MAX.to_bits());
1374            assert_eq!(
1375                wm.current_position.y.to_bits(),
1376                wm.start_position.y.to_bits()
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn file_drop_empty_file_list_is_allowed() {
1383        let ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
1384        let fd = ctx.as_file_drop().unwrap();
1385        assert_eq!(fd.files.len(), 0);
1386        assert!(fd.drop_target.into_option().is_none());
1387        assert_eq!(fd.drop_effect, DropEffect::Copy);
1388    }
1389
1390    #[test]
1391    fn file_drop_unicode_and_pathological_filenames_round_trip() {
1392        let files = alloc::vec![
1393            AzString::from(""),
1394            AzString::from("/tmp/\u{1F4C1}/f\u{0301}ile.txt"),
1395            AzString::from("C:\\Windows\\..\\..\\etc\\passwd"),
1396            AzString::from("a".repeat(4096)),
1397            AzString::from("with\nnewline\tand\u{0}nul"),
1398        ];
1399        let ctx = DragContext::file_drop(files, LogicalPosition::new(1.0, 2.0), 42);
1400        let fd = ctx.as_file_drop().unwrap();
1401        assert_eq!(fd.files.len(), 5);
1402        assert_eq!(fd.files.as_slice()[0].as_str(), "");
1403        assert_eq!(fd.files.as_slice()[3].as_str().len(), 4096);
1404        assert_eq!(fd.files.as_slice()[4].as_str(), "with\nnewline\tand\u{0}nul");
1405        assert_eq!(ctx.session_id, 42);
1406    }
1407
1408    #[test]
1409    fn file_drop_ten_thousand_files_does_not_hang() {
1410        let mut files = Vec::with_capacity(10_000);
1411        for i in 0..10_000usize {
1412            files.push(AzString::from(i.to_string()));
1413        }
1414        let ctx = DragContext::file_drop(files, LogicalPosition::zero(), 1);
1415        assert_eq!(ctx.as_file_drop().unwrap().files.len(), 10_000);
1416    }
1417
1418    // ================================================ predicates / accessors
1419
1420    fn one_of_each() -> [DragContext; 6] {
1421        [
1422            DragContext::text_selection(dom(0), NodeId::ZERO, LogicalPosition::zero(), 0),
1423            vscroll(0.0, 100.0, 200.0, 100.0),
1424            DragContext::node_drag(
1425                dom(0),
1426                NodeId::ZERO,
1427                LogicalPosition::zero(),
1428                DragData::new(),
1429                0,
1430            ),
1431            DragContext::window_move(
1432                LogicalPosition::zero(),
1433                WindowPosition::Uninitialized,
1434                0,
1435            ),
1436            resize_ctx(),
1437            DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0),
1438        ]
1439    }
1440
1441    #[test]
1442    fn predicates_are_mutually_exclusive_across_every_variant() {
1443        for (i, ctx) in one_of_each().iter().enumerate() {
1444            let flags = [
1445                ctx.is_text_selection(),
1446                ctx.is_scrollbar_thumb(),
1447                ctx.is_node_drag(),
1448                ctx.is_window_move(),
1449                ctx.is_file_drop(),
1450            ];
1451            let set = flags.iter().filter(|f| **f).count();
1452            if i == 4 {
1453                // WindowResize has no predicate: every is_* must be false.
1454                assert_eq!(set, 0, "WindowResize matched a predicate");
1455            } else {
1456                assert_eq!(set, 1, "variant {i} matched {set} predicates, expected 1");
1457                assert!(flags[if i == 5 { 4 } else { i }], "wrong predicate for {i}");
1458            }
1459        }
1460    }
1461
1462    #[test]
1463    fn as_accessors_return_none_for_every_non_matching_variant() {
1464        for (i, ctx) in one_of_each().iter().enumerate() {
1465            assert_eq!(ctx.as_text_selection().is_some(), i == 0);
1466            assert_eq!(ctx.as_scrollbar_thumb().is_some(), i == 1);
1467            assert_eq!(ctx.as_node_drag().is_some(), i == 2);
1468            assert_eq!(ctx.as_window_move().is_some(), i == 3);
1469            assert_eq!(ctx.as_file_drop().is_some(), i == 5);
1470        }
1471    }
1472
1473    #[test]
1474    fn as_mut_accessors_return_none_for_every_non_matching_variant() {
1475        for (i, ctx) in one_of_each().iter_mut().enumerate() {
1476            assert_eq!(ctx.as_text_selection_mut().is_some(), i == 0);
1477            assert_eq!(ctx.as_scrollbar_thumb_mut().is_some(), i == 1);
1478            assert_eq!(ctx.as_node_drag_mut().is_some(), i == 2);
1479            assert_eq!(ctx.as_file_drop_mut().is_some(), i == 5);
1480        }
1481    }
1482
1483    #[test]
1484    fn as_text_selection_mut_writes_are_visible_through_shared_getter() {
1485        let mut ctx =
1486            DragContext::text_selection(dom(0), NodeId::new(1), LogicalPosition::zero(), 0);
1487        {
1488            let ts = ctx.as_text_selection_mut().unwrap();
1489            ts.auto_scroll_direction = AutoScrollDirection::DownRight;
1490            ts.auto_scroll_container = Some(NodeId::new(77));
1491        }
1492        let ts = ctx.as_text_selection().unwrap();
1493        assert_eq!(ts.auto_scroll_direction, AutoScrollDirection::DownRight);
1494        assert_eq!(ts.auto_scroll_container, Some(NodeId::new(77)));
1495    }
1496
1497    #[test]
1498    fn as_scrollbar_thumb_mut_writes_are_visible_through_shared_getter() {
1499        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1500        ctx.as_scrollbar_thumb_mut().unwrap().start_scroll_offset = f32::NAN;
1501        assert!(ctx.as_scrollbar_thumb().unwrap().start_scroll_offset.is_nan());
1502    }
1503
1504    #[test]
1505    fn as_node_drag_mut_writes_are_visible_through_shared_getter() {
1506        let mut ctx = DragContext::node_drag(
1507            dom(0),
1508            NodeId::ZERO,
1509            LogicalPosition::zero(),
1510            DragData::new(),
1511            0,
1512        );
1513        {
1514            let nd = ctx.as_node_drag_mut().unwrap();
1515            nd.drop_accepted = true;
1516            nd.drop_effect = DropEffect::Move;
1517            nd.current_drop_target = Some(dnid(0, 4)).into();
1518        }
1519        let nd = ctx.as_node_drag().unwrap();
1520        assert!(nd.drop_accepted);
1521        assert_eq!(nd.drop_effect, DropEffect::Move);
1522        assert_eq!(
1523            nd.current_drop_target
1524                .into_option()
1525                .unwrap()
1526                .node
1527                .into_crate_internal(),
1528            Some(NodeId::new(4))
1529        );
1530    }
1531
1532    #[test]
1533    fn as_file_drop_mut_writes_are_visible_through_shared_getter() {
1534        let mut ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
1535        ctx.as_file_drop_mut().unwrap().drop_effect = DropEffect::Link;
1536        assert_eq!(ctx.as_file_drop().unwrap().drop_effect, DropEffect::Link);
1537    }
1538
1539    // ============================================== update / position getters
1540
1541    #[test]
1542    fn update_position_moves_current_for_every_variant_and_leaves_start_alone() {
1543        let new_pos = LogicalPosition::new(123.5, -456.25);
1544        for (i, mut ctx) in one_of_each().into_iter().enumerate() {
1545            let start_before = ctx.start_position();
1546            ctx.update_position(new_pos);
1547            assert_eq!(ctx.current_position(), new_pos, "variant {i} did not move");
1548            if i == 5 {
1549                // FileDrop has a single `position` field: start aliases current,
1550                // so updating the position also moves the reported start.
1551                assert_eq!(ctx.start_position(), new_pos);
1552            } else {
1553                assert_eq!(ctx.start_position(), start_before, "variant {i} start moved");
1554            }
1555        }
1556    }
1557
1558    #[test]
1559    fn update_position_with_nan_and_inf_is_stored_verbatim() {
1560        for mut ctx in one_of_each() {
1561            ctx.update_position(LogicalPosition::new(f32::NAN, f32::INFINITY));
1562            let cur = ctx.current_position();
1563            assert!(cur.x.is_nan());
1564            assert_eq!(cur.y, f32::INFINITY);
1565
1566            ctx.update_position(LogicalPosition::new(f32::MIN, f32::NEG_INFINITY));
1567            let cur = ctx.current_position();
1568            assert_eq!(cur.x.to_bits(), f32::MIN.to_bits());
1569            assert_eq!(cur.y, f32::NEG_INFINITY);
1570        }
1571    }
1572
1573    #[test]
1574    fn update_position_is_idempotent_and_last_write_wins() {
1575        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1576        for i in 0..1000u32 {
1577            ctx.update_position(LogicalPosition::new(i as f32, -(i as f32)));
1578        }
1579        assert_eq!(ctx.current_position(), LogicalPosition::new(999.0, -999.0));
1580        assert_eq!(ctx.start_position(), LogicalPosition::zero());
1581    }
1582
1583    #[test]
1584    fn window_resize_position_getters_work_without_a_predicate() {
1585        let mut ctx = resize_ctx();
1586        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
1587        assert_eq!(ctx.current_position(), LogicalPosition::new(1.0, 2.0));
1588        ctx.update_position(LogicalPosition::new(-3.0, -4.0));
1589        assert_eq!(ctx.current_position(), LogicalPosition::new(-3.0, -4.0));
1590        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
1591        // No as_window_resize() accessor exists; the others must all say None.
1592        assert!(ctx.as_text_selection().is_none());
1593        assert!(ctx.as_window_move().is_none());
1594    }
1595
1596    // ===================================== calculate_scrollbar_scroll_offset
1597
1598    #[test]
1599    fn scroll_offset_is_none_for_non_scrollbar_drags() {
1600        for (i, ctx) in one_of_each().iter().enumerate() {
1601            if i == 1 {
1602                continue;
1603            }
1604            assert!(
1605                ctx.calculate_scrollbar_scroll_offset().is_none(),
1606                "variant {i} returned Some"
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn scroll_offset_basic_vertical_half_track() {
1613        // track=100, content=200, viewport=100 => range=100, thumb=50,
1614        // scrollable_track=50. A 25px drag is half the scrollable track =>
1615        // half the range = 50.
1616        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1617        ctx.update_position(LogicalPosition::new(0.0, 25.0));
1618        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
1619    }
1620
1621    #[test]
1622    fn scroll_offset_horizontal_uses_x_and_ignores_y() {
1623        let mut ctx = DragContext::scrollbar_thumb(
1624            dom(0),
1625            NodeId::new(1),
1626            ScrollbarAxis::Horizontal,
1627            LogicalPosition::zero(),
1628            0.0,
1629            100.0,
1630            200.0,
1631            100.0,
1632            0,
1633        );
1634        // Pure vertical movement must not scroll a horizontal scrollbar.
1635        ctx.update_position(LogicalPosition::new(0.0, 9999.0));
1636        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1637        ctx.update_position(LogicalPosition::new(25.0, 9999.0));
1638        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
1639    }
1640
1641    #[test]
1642    fn scroll_offset_clamps_to_range_on_huge_and_infinite_drags() {
1643        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // range = 100
1644        for (y, expect) in [
1645            (1.0e30f32, 100.0f32),
1646            (f32::MAX, 100.0),
1647            (f32::INFINITY, 100.0),
1648            (-1.0e30, 0.0),
1649            (f32::MIN, 0.0),
1650            (f32::NEG_INFINITY, 0.0),
1651        ] {
1652            ctx.update_position(LogicalPosition::new(0.0, y));
1653            assert_eq!(
1654                ctx.calculate_scrollbar_scroll_offset(),
1655                Some(expect),
1656                "y = {y}"
1657            );
1658        }
1659    }
1660
1661    #[test]
1662    fn scroll_offset_result_always_within_range_for_finite_inputs() {
1663        let mut ctx = vscroll(30.0, 80.0, 500.0, 120.0);
1664        let range = 500.0 - 120.0;
1665        for y in [-1e9f32, -1.0, 0.0, 0.5, 1.0, 37.0, 1e9] {
1666            ctx.update_position(LogicalPosition::new(0.0, y));
1667            let off = ctx.calculate_scrollbar_scroll_offset().unwrap();
1668            assert!(
1669                (0.0..=range).contains(&off),
1670                "offset {off} escaped [0, {range}] for y = {y}"
1671            );
1672        }
1673    }
1674
1675    #[test]
1676    fn scroll_offset_out_of_range_start_offset_is_clamped_back_in() {
1677        // Even with zero mouse movement, a bogus start offset must be clamped.
1678        let ctx = vscroll(9999.0, 100.0, 200.0, 100.0);
1679        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(100.0));
1680        let ctx = vscroll(-9999.0, 100.0, 200.0, 100.0);
1681        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1682    }
1683
1684    #[test]
1685    fn scroll_offset_non_scrollable_content_returns_start_offset_unchanged() {
1686        // content <= viewport => nothing to scroll; the (possibly out-of-range)
1687        // start offset is returned verbatim, WITHOUT clamping.
1688        let mut ctx = vscroll(7.0, 100.0, 50.0, 100.0);
1689        ctx.update_position(LogicalPosition::new(0.0, 1000.0));
1690        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1691
1692        let ctx = vscroll(7.0, 100.0, 100.0, 100.0); // range == 0
1693        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1694
1695        let ctx = vscroll(7.0, 100.0, 0.0, 0.0); // all zero metrics
1696        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1697    }
1698
1699    #[test]
1700    fn scroll_offset_zero_or_negative_track_returns_start_offset() {
1701        for track in [0.0f32, -1.0, -1e30, f32::NEG_INFINITY] {
1702            let mut ctx = vscroll(7.0, track, 200.0, 100.0);
1703            ctx.update_position(LogicalPosition::new(0.0, 50.0));
1704            assert_eq!(
1705                ctx.calculate_scrollbar_scroll_offset(),
1706                Some(7.0),
1707                "track = {track}"
1708            );
1709        }
1710    }
1711
1712    #[test]
1713    fn scroll_offset_negative_content_and_viewport_return_start_offset() {
1714        let mut ctx = vscroll(3.0, 100.0, -200.0, -100.0);
1715        ctx.update_position(LogicalPosition::new(0.0, 50.0));
1716        // range = -200 - (-100) = -100 <= 0 => early out.
1717        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(3.0));
1718    }
1719
1720    #[test]
1721    fn scroll_offset_nan_start_offset_propagates_nan_without_panicking() {
1722        let mut ctx = vscroll(f32::NAN, 100.0, 200.0, 100.0);
1723        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1724        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1725        // NaN start offset is neither clamped nor rejected: it leaks through.
1726        assert!(off.is_nan());
1727    }
1728
1729    #[test]
1730    fn scroll_offset_nan_track_length_propagates_nan_without_panicking() {
1731        let mut ctx = vscroll(0.0, f32::NAN, 200.0, 100.0);
1732        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1733        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1734        // NaN track passes the `track_length_px <= 0.0` guard (NaN compares
1735        // false) and poisons the result. min/max of the clamp stay finite, so
1736        // no panic — just a NaN scroll offset handed to the caller.
1737        assert!(off.is_nan());
1738    }
1739
1740    #[test]
1741    fn scroll_offset_nan_mouse_position_propagates_nan_without_panicking() {
1742        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1743        ctx.update_position(LogicalPosition::new(0.0, f32::NAN));
1744        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1745        assert!(off.is_nan());
1746    }
1747
1748    #[test]
1749    fn scroll_offset_infinite_content_yields_nan_on_zero_mouse_delta() {
1750        // range = inf, thumb = 0, ratio = 0 => scroll_delta = 0.0 * inf = NaN.
1751        // Not a panic (clamp's min/max are 0.0/inf), but the returned offset is
1752        // NaN even though the mouse never moved.
1753        let ctx = vscroll(0.0, 100.0, f32::INFINITY, 100.0);
1754        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1755        assert!(off.is_nan(), "expected the 0 * inf NaN, got {off}");
1756    }
1757
1758    // A NaN `scrollable_range` (from a NaN, or inf-minus-inf, content/viewport
1759    // length) used to make `new_offset.clamp(0.0, scrollable_range)` PANIC inside
1760    // this getter (f32::clamp asserts min <= max, and every NaN comparison is
1761    // false), taking the app down on the next scrollbar drag. The guard now uses
1762    // `!(range > 0.0)`, so a NaN range falls back to the start offset.
1763    #[test]
1764    fn scroll_offset_nan_content_length_falls_back_without_panicking() {
1765        let mut ctx = vscroll(0.0, 100.0, f32::NAN, 100.0);
1766        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1767        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1768    }
1769
1770    #[test]
1771    fn scroll_offset_nan_viewport_length_falls_back_without_panicking() {
1772        let mut ctx = vscroll(0.0, 100.0, 200.0, f32::NAN);
1773        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1774        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1775    }
1776
1777    #[test]
1778    fn scroll_offset_infinite_content_and_viewport_falls_back_without_panicking() {
1779        // inf - inf = NaN scrollable_range => the guard falls back to start.
1780        let mut ctx = vscroll(0.0, 100.0, f32::INFINITY, f32::INFINITY);
1781        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1782        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1783    }
1784
1785    // ================================================ remap_node_ids / targets
1786
1787    #[test]
1788    fn remap_text_selection_other_dom_is_a_no_op_and_succeeds() {
1789        let mut ctx =
1790            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
1791        assert!(ctx.remap_node_ids(dom(1), &nid_map(&[(5, 500)])));
1792        assert_eq!(ctx.as_text_selection().unwrap().anchor_ifc_node, NodeId::new(5));
1793    }
1794
1795    #[test]
1796    fn remap_text_selection_missing_anchor_cancels_the_drag() {
1797        let mut ctx =
1798            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
1799        assert!(!ctx.remap_node_ids(dom(0), &BTreeMap::new()));
1800        assert!(!ctx.remap_node_ids(dom(0), &nid_map(&[(6, 7)])));
1801        // The anchor is left untouched when the remap fails.
1802        assert_eq!(ctx.as_text_selection().unwrap().anchor_ifc_node, NodeId::new(5));
1803    }
1804
1805    #[test]
1806    fn remap_text_selection_clears_only_the_missing_auto_scroll_container() {
1807        let mut ctx =
1808            DragContext::text_selection(dom(0), NodeId::new(5), LogicalPosition::zero(), 0);
1809        ctx.as_text_selection_mut().unwrap().auto_scroll_container = Some(NodeId::new(9));
1810
1811        // Container survives if it is in the map.
1812        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(5, 50), (9, 90)])));
1813        let ts = ctx.as_text_selection().unwrap();
1814        assert_eq!(ts.anchor_ifc_node, NodeId::new(50));
1815        assert_eq!(ts.auto_scroll_container, Some(NodeId::new(90)));
1816
1817        // Container is dropped (but the drag survives) if it is gone.
1818        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(50, 51)])));
1819        let ts = ctx.as_text_selection().unwrap();
1820        assert_eq!(ts.anchor_ifc_node, NodeId::new(51));
1821        assert_eq!(ts.auto_scroll_container, None);
1822    }
1823
1824    #[test]
1825    fn remap_scrollbar_empty_map_cancels_the_drag() {
1826        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // node 1, dom 0
1827        assert!(!ctx.remap_node_ids(dom(0), &BTreeMap::new()));
1828        assert_eq!(
1829            ctx.as_scrollbar_thumb().unwrap().scroll_container_node,
1830            NodeId::new(1)
1831        );
1832    }
1833
1834    #[test]
1835    fn remap_node_drag_missing_node_cancels_and_leaves_targets_alone() {
1836        let mut ctx = DragContext::node_drag(
1837            dom(0),
1838            NodeId::new(1),
1839            LogicalPosition::zero(),
1840            DragData::new(),
1841            0,
1842        );
1843        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid(0, 5)).into();
1844
1845        // Map covers the drop target but NOT the dragged node => cancel.
1846        assert!(!ctx.remap_node_ids(dom(0), &nid_map(&[(5, 50)])));
1847        let nd = ctx.as_node_drag().unwrap();
1848        assert_eq!(nd.node_id, NodeId::new(1));
1849        assert_eq!(
1850            nd.current_drop_target
1851                .into_option()
1852                .unwrap()
1853                .node
1854                .into_crate_internal(),
1855            Some(NodeId::new(5)),
1856            "targets must not be half-remapped on a cancelled drag"
1857        );
1858    }
1859
1860    #[test]
1861    fn remap_node_drag_clears_drop_targets_that_were_removed() {
1862        let mut ctx = DragContext::node_drag(
1863            dom(0),
1864            NodeId::new(1),
1865            LogicalPosition::zero(),
1866            DragData::new(),
1867            0,
1868        );
1869        {
1870            let nd = ctx.as_node_drag_mut().unwrap();
1871            nd.current_drop_target = Some(dnid(0, 5)).into();
1872            nd.previous_drop_target = Some(dnid(0, 6)).into();
1873        }
1874        // Only the dragged node survives; both targets are gone.
1875        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 100)])));
1876        let nd = ctx.as_node_drag().unwrap();
1877        assert_eq!(nd.node_id, NodeId::new(100));
1878        assert!(nd.current_drop_target.into_option().is_none());
1879        assert!(nd.previous_drop_target.into_option().is_none());
1880    }
1881
1882    #[test]
1883    fn remap_does_not_touch_drop_targets_belonging_to_another_dom() {
1884        let mut ctx = DragContext::node_drag(
1885            dom(0),
1886            NodeId::new(1),
1887            LogicalPosition::zero(),
1888            DragData::new(),
1889            0,
1890        );
1891        // Drop target lives in DOM 1 (a different DOM than the drag).
1892        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid(1, 5)).into();
1893
1894        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 100), (5, 500)])));
1895        let nd = ctx.as_node_drag().unwrap();
1896        assert_eq!(nd.node_id, NodeId::new(100));
1897        let dt = nd.current_drop_target.into_option().unwrap();
1898        assert_eq!(dt.dom, dom(1));
1899        assert_eq!(dt.node.into_crate_internal(), Some(NodeId::new(5)));
1900    }
1901
1902    #[test]
1903    fn remap_drop_target_without_a_node_id_is_left_intact() {
1904        // A DomNodeId whose node encodes `None` must not be cleared or panic.
1905        let mut ctx = DragContext::node_drag(
1906            dom(0),
1907            NodeId::new(1),
1908            LogicalPosition::zero(),
1909            DragData::new(),
1910            0,
1911        );
1912        ctx.as_node_drag_mut().unwrap().current_drop_target = Some(dnid_no_node(0)).into();
1913
1914        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(1, 1)])));
1915        let dt = ctx
1916            .as_node_drag()
1917            .unwrap()
1918            .current_drop_target
1919            .into_option()
1920            .expect("target must survive");
1921        assert_eq!(dt.dom, dom(0));
1922        assert_eq!(dt.node.into_crate_internal(), None);
1923    }
1924
1925    #[test]
1926    fn remap_drop_target_directly_handles_none_and_foreign_doms() {
1927        // Exercise the private helper on its own.
1928        let mut none_target = OptionDomNodeId::None;
1929        DragContext::remap_drop_target(&mut none_target, dom(0), &nid_map(&[(1, 2)]));
1930        assert!(none_target.into_option().is_none());
1931
1932        let mut foreign: OptionDomNodeId = Some(dnid(9, 1)).into();
1933        DragContext::remap_drop_target(&mut foreign, dom(0), &nid_map(&[(1, 2)]));
1934        let dt = foreign.into_option().unwrap();
1935        assert_eq!(dt.dom, dom(9));
1936        assert_eq!(dt.node.into_crate_internal(), Some(NodeId::new(1)));
1937
1938        // Empty map on a matching DOM clears the target.
1939        let mut matching: OptionDomNodeId = Some(dnid(0, 1)).into();
1940        DragContext::remap_drop_target(&mut matching, dom(0), &BTreeMap::new());
1941        assert!(matching.into_option().is_none());
1942    }
1943
1944    #[test]
1945    fn remap_file_drop_always_survives_and_clears_stale_targets() {
1946        let mut ctx = DragContext::file_drop(
1947            alloc::vec![AzString::from("/tmp/a")],
1948            LogicalPosition::zero(),
1949            0,
1950        );
1951        // No target: nothing to do, drag survives.
1952        assert!(ctx.remap_node_ids(dom(0), &BTreeMap::new()));
1953        assert!(ctx.as_file_drop().unwrap().drop_target.into_option().is_none());
1954
1955        // Target present and mapped => remapped.
1956        ctx.as_file_drop_mut().unwrap().drop_target = Some(dnid(0, 5)).into();
1957        assert!(ctx.remap_node_ids(dom(0), &nid_map(&[(5, 55)])));
1958        assert_eq!(
1959            ctx.as_file_drop()
1960                .unwrap()
1961                .drop_target
1962                .into_option()
1963                .unwrap()
1964                .node
1965                .into_crate_internal(),
1966            Some(NodeId::new(55))
1967        );
1968
1969        // Target removed => cleared, but the file drop itself still survives.
1970        assert!(ctx.remap_node_ids(dom(0), &BTreeMap::new()));
1971        assert!(ctx.as_file_drop().unwrap().drop_target.into_option().is_none());
1972        assert_eq!(ctx.as_file_drop().unwrap().files.len(), 1);
1973    }
1974
1975    #[test]
1976    fn remap_window_drags_always_succeed() {
1977        let mut wm = DragContext::window_move(
1978            LogicalPosition::zero(),
1979            WindowPosition::Uninitialized,
1980            0,
1981        );
1982        assert!(wm.remap_node_ids(dom(0), &BTreeMap::new()));
1983        assert!(wm.remap_node_ids(dom(usize::MAX), &nid_map(&[(1, 2)])));
1984
1985        let mut wr = resize_ctx();
1986        assert!(wr.remap_node_ids(dom(0), &BTreeMap::new()));
1987        assert_eq!(wr.current_position(), LogicalPosition::new(1.0, 2.0));
1988    }
1989
1990    #[test]
1991    fn remap_with_a_hundred_thousand_entries_does_not_hang() {
1992        let mut m = BTreeMap::new();
1993        for i in 0..100_000usize {
1994            m.insert(NodeId::new(i), NodeId::new(i + 1));
1995        }
1996        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // node 1
1997        assert!(ctx.remap_node_ids(dom(0), &m));
1998        assert_eq!(
1999            ctx.as_scrollbar_thumb().unwrap().scroll_container_node,
2000            NodeId::new(2)
2001        );
2002    }
2003
2004    #[test]
2005    fn remap_identity_map_is_a_fixed_point() {
2006        let mut ctx = DragContext::node_drag(
2007            dom(0),
2008            NodeId::new(1),
2009            LogicalPosition::new(1.0, 2.0),
2010            DragData::new(),
2011            0,
2012        );
2013        {
2014            let nd = ctx.as_node_drag_mut().unwrap();
2015            nd.current_drop_target = Some(dnid(0, 5)).into();
2016            nd.previous_drop_target = Some(dnid(0, 5)).into();
2017        }
2018        let before = ctx.clone();
2019        let m = nid_map(&[(1, 1), (5, 5)]);
2020        assert!(ctx.remap_node_ids(dom(0), &m));
2021        assert!(ctx.remap_node_ids(dom(0), &m)); // twice, for good measure
2022        assert_eq!(ctx, before);
2023    }
2024
2025    // ==================================================== DragDelta
2026
2027    #[test]
2028    fn drag_delta_new_stores_extremes_verbatim() {
2029        for (dx, dy) in [
2030            (0.0f32, 0.0f32),
2031            (f32::MAX, f32::MIN),
2032            (f32::INFINITY, f32::NEG_INFINITY),
2033            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
2034        ] {
2035            let d = DragDelta::new(dx, dy);
2036            assert_eq!(d.dx.to_bits(), dx.to_bits());
2037            assert_eq!(d.dy.to_bits(), dy.to_bits());
2038        }
2039    }
2040
2041    #[test]
2042    fn drag_delta_zero_is_the_neutral_default() {
2043        let z = DragDelta::zero();
2044        assert_eq!(z.dx, 0.0);
2045        assert_eq!(z.dy, 0.0);
2046        assert!(!z.dx.is_sign_negative(), "zero() must be +0.0, not -0.0");
2047        assert_eq!(z, DragDelta::default());
2048        assert_eq!(z, DragDelta::new(0.0, 0.0));
2049        // IEEE-754: -0.0 == +0.0, so a negative zero delta still equals zero().
2050        assert_eq!(DragDelta::new(-0.0, -0.0), z);
2051        // ...but the sign bit is preserved in the stored field.
2052        assert!(DragDelta::new(-0.0, -0.0).dx.is_sign_negative());
2053    }
2054
2055    #[test]
2056    fn drag_delta_nan_is_not_equal_to_itself() {
2057        // Derived PartialEq on f32 => NaN != NaN. Callers must not use
2058        // equality to detect "no movement" on a NaN delta.
2059        let n = DragDelta::new(f32::NAN, f32::NAN);
2060        assert_ne!(n, DragDelta::new(f32::NAN, f32::NAN));
2061        assert_ne!(n, DragDelta::zero());
2062        assert!(n.dx.is_nan() && n.dy.is_nan());
2063        // PartialOrd is also useless on NaN: no ordering at all.
2064        assert!(n.partial_cmp(&DragDelta::zero()).is_none());
2065    }
2066}