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        if scrollable_range <= 0.0 || drag.track_length_px <= 0.0 {
651            return Some(drag.start_scroll_offset);
652        }
653
654        // Calculate thumb length (proportional to viewport/content ratio)
655        let thumb_length = (drag.viewport_length_px / drag.content_length_px) * drag.track_length_px;
656        let scrollable_track = drag.track_length_px - thumb_length;
657
658        if scrollable_track <= 0.0 {
659            return Some(drag.start_scroll_offset);
660        }
661
662        // Convert mouse delta to scroll delta
663        let scroll_ratio = mouse_delta / scrollable_track;
664        let scroll_delta = scroll_ratio * scrollable_range;
665
666        // Calculate new scroll offset
667        let new_offset = drag.start_scroll_offset + scroll_delta;
668
669        // Clamp to valid range
670        Some(new_offset.clamp(0.0, scrollable_range))
671    }
672
673    /// Remap a drop target's `NodeId` using the old→new mapping.
674    /// Clears the target if the old `NodeId` was removed.
675    fn remap_drop_target(
676        target: &mut OptionDomNodeId,
677        dom_id: DomId,
678        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
679    ) {
680        let dt = match target.into_option() {
681            Some(dt) if dt.dom == dom_id => dt,
682            _ => return,
683        };
684        let Some(old_nid) = dt.node.into_crate_internal() else {
685            return;
686        };
687        if let Some(&new_nid) = node_id_map.get(&old_nid) {
688            *target = Some(DomNodeId {
689                dom: dom_id,
690                node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(new_nid)),
691            }).into();
692        } else {
693            *target = OptionDomNodeId::None;
694        }
695    }
696
697    /// Remap `NodeIds` stored in this drag context after DOM reconciliation.
698    ///
699    /// When the DOM is regenerated during an active drag, `NodeIds` can change.
700    /// This updates all stored `NodeIds` using the old→new mapping.
701    /// Returns `false` if a critical `NodeId` was removed (drag should be cancelled).
702    pub fn remap_node_ids(
703        &mut self,
704        dom_id: DomId,
705        node_id_map: &alloc::collections::BTreeMap<NodeId, NodeId>,
706    ) -> bool {
707        match &mut self.drag_type {
708            ActiveDragType::TextSelection(ref mut drag) => {
709                if drag.dom_id != dom_id {
710                    return true;
711                }
712                if let Some(&new_id) = node_id_map.get(&drag.anchor_ifc_node) {
713                    drag.anchor_ifc_node = new_id;
714                } else {
715                    return false; // anchor node removed
716                }
717                if let Some(ref mut container) = drag.auto_scroll_container {
718                    if let Some(&new_id) = node_id_map.get(container) {
719                        *container = new_id;
720                    } else {
721                        drag.auto_scroll_container = None;
722                    }
723                }
724                true
725            }
726            ActiveDragType::ScrollbarThumb(ref mut drag) => {
727                // Scope the remap to the DOM this drag belongs to: a different
728                // DOM's reconciliation must not touch our scroll container id.
729                if drag.dom_id != dom_id {
730                    return true;
731                }
732                if let Some(&new_id) = node_id_map.get(&drag.scroll_container_node) {
733                    drag.scroll_container_node = new_id;
734                    true
735                } else {
736                    false // scroll container removed
737                }
738            }
739            ActiveDragType::Node(ref mut drag) => {
740                if drag.dom_id != dom_id {
741                    return true;
742                }
743                if let Some(&new_id) = node_id_map.get(&drag.node_id) {
744                    drag.node_id = new_id;
745                } else {
746                    return false; // dragged node removed
747                }
748                // Drop target remap — both current AND previous, otherwise a
749                // stale `previous_drop_target` keeps a pre-reconciliation NodeId
750                // and later generates spurious DragEnter/DragLeave against a
751                // node that no longer exists (or a different node reusing the id).
752                Self::remap_drop_target(&mut drag.current_drop_target, dom_id, node_id_map);
753                Self::remap_drop_target(&mut drag.previous_drop_target, dom_id, node_id_map);
754                true
755            }
756            // WindowMove, WindowResize, and FileDrop don't reference DOM NodeIds
757            ActiveDragType::WindowMove(_) | ActiveDragType::WindowResize(_) => true,
758            ActiveDragType::FileDrop(ref mut drag) => {
759                Self::remap_drop_target(&mut drag.drop_target, dom_id, node_id_map);
760                true
761            }
762        }
763    }
764}
765
766azul_css::impl_option!(
767    DragContext,
768    OptionDragContext,
769    copy = false,
770    [Debug, Clone, PartialEq]
771);
772
773
774/// Drag offset from the cursor position at drag start (logical pixels).
775/// `dx`/`dy` are the delta from drag start to current position.
776#[derive(Default, Debug, Copy, Clone, PartialEq, PartialOrd)]
777#[repr(C)]
778pub struct DragDelta {
779    pub dx: f32,
780    pub dy: f32,
781}
782
783impl DragDelta {
784    #[inline]
785    #[must_use] pub const fn new(dx: f32, dy: f32) -> Self {
786        Self { dx, dy }
787    }
788    #[inline]
789    #[must_use] pub const fn zero() -> Self {
790        Self::new(0.0, 0.0)
791    }
792}
793
794impl_option!(
795    DragDelta,
796    OptionDragDelta,
797    [Debug, Copy, Clone, PartialEq, PartialOrd]
798);
799
800#[cfg(test)]
801mod audit_tests {
802    use super::*;
803    use crate::styled_dom::NodeHierarchyItemId;
804
805    fn node_map(from: usize, to: usize) -> alloc::collections::BTreeMap<NodeId, NodeId> {
806        let mut m = alloc::collections::BTreeMap::new();
807        m.insert(NodeId::new(from), NodeId::new(to));
808        m
809    }
810
811    fn dnid(dom: usize, node: usize) -> DomNodeId {
812        DomNodeId {
813            dom: DomId { inner: dom },
814            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
815        }
816    }
817
818    #[test]
819    fn scrollbar_remap_scoped_to_dom() {
820        let mut ctx = DragContext::scrollbar_thumb(
821            DomId { inner: 0 },
822            NodeId::new(3),
823            ScrollbarAxis::Vertical,
824            LogicalPosition::zero(),
825            0.0, 100.0, 300.0, 100.0,
826            1,
827        );
828        // Reconciling a *different* DOM (id 1) must not touch our node id.
829        let ok = ctx.remap_node_ids(DomId { inner: 1 }, &node_map(3, 99));
830        assert!(ok);
831        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(3));
832
833        // Reconciling our own DOM (id 0) remaps it.
834        let ok2 = ctx.remap_node_ids(DomId { inner: 0 }, &node_map(3, 99));
835        assert!(ok2);
836        assert_eq!(ctx.as_scrollbar_thumb().unwrap().scroll_container_node, NodeId::new(99));
837    }
838
839    #[test]
840    fn node_drag_remaps_previous_drop_target() {
841        let mut ctx = DragContext::node_drag(
842            DomId { inner: 0 },
843            NodeId::new(1),
844            LogicalPosition::zero(),
845            DragData::new(),
846            2,
847        );
848        {
849            let nd = ctx.as_node_drag_mut().unwrap();
850            nd.current_drop_target = Some(dnid(0, 5)).into();
851            nd.previous_drop_target = Some(dnid(0, 6)).into();
852        }
853        // Map: dragged node 1->1, drop targets 5->50, 6->60.
854        let mut m = alloc::collections::BTreeMap::new();
855        m.insert(NodeId::new(1), NodeId::new(1));
856        m.insert(NodeId::new(5), NodeId::new(50));
857        m.insert(NodeId::new(6), NodeId::new(60));
858        assert!(ctx.remap_node_ids(DomId { inner: 0 }, &m));
859
860        let nd = ctx.as_node_drag().unwrap();
861        let cur = nd.current_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
862        let prev = nd.previous_drop_target.into_option().unwrap().node.into_crate_internal().unwrap();
863        assert_eq!(cur, NodeId::new(50));
864        assert_eq!(prev, NodeId::new(60)); // previously left stale (bug)
865    }
866}
867
868#[cfg(test)]
869mod autotest_generated {
870    use alloc::collections::BTreeMap;
871    use alloc::string::{String, ToString};
872
873    use super::*;
874    use crate::geom::PhysicalPosition;
875    use crate::styled_dom::NodeHierarchyItemId;
876
877    // ---------------------------------------------------------------- helpers
878
879    fn dom(i: usize) -> DomId {
880        DomId { inner: i }
881    }
882
883    fn dnid(dom_idx: usize, node: usize) -> DomNodeId {
884        DomNodeId {
885            dom: dom(dom_idx),
886            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
887        }
888    }
889
890    /// A drop target that points at a DOM but carries *no* node id (the `None`
891    /// encoding of `NodeHierarchyItemId`).
892    fn dnid_no_node(dom_idx: usize) -> DomNodeId {
893        DomNodeId {
894            dom: dom(dom_idx),
895            node: NodeHierarchyItemId::from_crate_internal(None),
896        }
897    }
898
899    fn nid_map(pairs: &[(usize, usize)]) -> BTreeMap<NodeId, NodeId> {
900        let mut m = BTreeMap::new();
901        for (from, to) in pairs {
902            m.insert(NodeId::new(*from), NodeId::new(*to));
903        }
904        m
905    }
906
907    /// Vertical scrollbar drag anchored at (0, 0), no mouse movement yet.
908    fn vscroll(
909        start_scroll_offset: f32,
910        track_length_px: f32,
911        content_length_px: f32,
912        viewport_length_px: f32,
913    ) -> DragContext {
914        DragContext::scrollbar_thumb(
915            dom(0),
916            NodeId::new(1),
917            ScrollbarAxis::Vertical,
918            LogicalPosition::zero(),
919            start_scroll_offset,
920            track_length_px,
921            content_length_px,
922            viewport_length_px,
923            7,
924        )
925    }
926
927    fn resize_ctx() -> DragContext {
928        DragContext::new(
929            ActiveDragType::WindowResize(WindowResizeDrag {
930                edge: WindowResizeEdge::BottomRight,
931                start_position: LogicalPosition::new(1.0, 2.0),
932                current_position: LogicalPosition::new(1.0, 2.0),
933                initial_width: u32::MAX,
934                initial_height: 0,
935            }),
936            u64::MAX,
937        )
938    }
939
940    // ============================================================ DragData
941    // parser-ish surface: get_data / set_data / set_text / get_text
942
943    #[test]
944    fn dragdata_new_is_empty_and_matches_default() {
945        let d = DragData::new();
946        assert_eq!(d.data.len(), 0);
947        assert!(d.data.is_empty());
948        assert_eq!(d.effect_allowed, DragEffect::Uninitialized);
949        assert_eq!(d, DragData::default());
950        assert!(d.get_data("text/plain").is_none());
951        assert!(d.get_text().is_none());
952    }
953
954    #[test]
955    fn get_data_valid_minimal_positive_control() {
956        let mut d = DragData::new();
957        d.set_data("text/plain", b"hi".to_vec());
958        assert_eq!(d.get_data("text/plain"), Some(&b"hi"[..]));
959    }
960
961    #[test]
962    fn get_data_empty_key_on_empty_and_populated_returns_none() {
963        let empty = DragData::new();
964        assert!(empty.get_data("").is_none());
965
966        let mut d = DragData::new();
967        d.set_data("text/plain", b"x".to_vec());
968        assert!(d.get_data("").is_none());
969    }
970
971    #[test]
972    fn get_data_empty_key_is_a_real_key_when_stored() {
973        // "" is not special-cased: it is a perfectly good (if silly) map key.
974        let mut d = DragData::new();
975        d.set_data("", b"empty-key".to_vec());
976        assert_eq!(d.get_data(""), Some(&b"empty-key"[..]));
977        assert!(d.get_data("text/plain").is_none());
978    }
979
980    #[test]
981    fn get_data_whitespace_only_keys_return_none() {
982        let mut d = DragData::new();
983        d.set_data("text/plain", b"x".to_vec());
984        for k in ["   ", "\t\n", "\r\n", "\u{a0}", "\u{2028}"] {
985            assert!(d.get_data(k).is_none(), "whitespace key {k:?} matched");
986        }
987    }
988
989    #[test]
990    fn get_data_garbage_bytes_return_none_without_panicking() {
991        let mut d = DragData::new();
992        d.set_data("text/plain", b"x".to_vec());
993        for k in [
994            "\u{0}",
995            "\u{0}\u{1}\u{2}\u{7f}",
996            "\u{feff}",
997            "%%%;;;///",
998            "text/plain\u{0}",
999            "\u{0}text/plain",
1000        ] {
1001            assert!(d.get_data(k).is_none(), "garbage key {k:?} matched");
1002        }
1003    }
1004
1005    #[test]
1006    fn get_data_leading_trailing_junk_is_not_trimmed() {
1007        let mut d = DragData::new();
1008        d.set_data("text/plain", b"x".to_vec());
1009        // Lookup is an exact byte-for-byte match: no trimming, no tolerance.
1010        assert!(d.get_data("  text/plain  ").is_none());
1011        assert!(d.get_data("text/plain;garbage").is_none());
1012        assert!(d.get_data("text/plain ").is_none());
1013        assert!(d.get_data(" text/plain").is_none());
1014        assert_eq!(d.get_data("text/plain"), Some(&b"x"[..]));
1015    }
1016
1017    #[test]
1018    fn get_data_is_case_sensitive() {
1019        // MIME types are case-insensitive per RFC 2045, but this map is not.
1020        let mut d = DragData::new();
1021        d.set_data("text/plain", b"x".to_vec());
1022        assert!(d.get_data("TEXT/PLAIN").is_none());
1023        assert!(d.get_data("Text/Plain").is_none());
1024    }
1025
1026    #[test]
1027    fn get_data_boundary_number_strings_return_none() {
1028        let mut d = DragData::new();
1029        d.set_data("text/plain", b"x".to_vec());
1030        for k in [
1031            "0",
1032            "-0",
1033            "9223372036854775807",  // i64::MAX
1034            "-9223372036854775808", // i64::MIN
1035            "18446744073709551615", // u64::MAX
1036            "1e400",
1037            "NaN",
1038            "inf",
1039            "-inf",
1040            "1.7976931348623157e308",
1041            "5e-324",
1042        ] {
1043            assert!(d.get_data(k).is_none(), "numeric key {k:?} matched");
1044        }
1045    }
1046
1047    #[test]
1048    fn get_data_unicode_keys_round_trip() {
1049        let mut d = DragData::new();
1050        let emoji = "application/x-\u{1F600};charset=utf-8";
1051        let combining = "text/e\u{0301}"; // e + combining acute
1052        d.set_data(emoji, b"grin".to_vec());
1053        d.set_data(combining, b"acute".to_vec());
1054
1055        assert_eq!(d.get_data(emoji), Some(&b"grin"[..]));
1056        assert_eq!(d.get_data(combining), Some(&b"acute"[..]));
1057        // NFC-equivalent but byte-distinct key must NOT match (no normalization).
1058        assert!(d.get_data("text/\u{e9}").is_none());
1059        assert_eq!(d.data.len(), 2);
1060    }
1061
1062    #[test]
1063    fn get_data_extremely_long_key_does_not_panic_or_hang() {
1064        let huge: String = "a".repeat(1_000_000);
1065        let mut d = DragData::new();
1066        d.set_data("text/plain", b"x".to_vec());
1067        // Miss against a 1M-char key.
1068        assert!(d.get_data(&huge).is_none());
1069        // Round-trip the 1M-char key itself.
1070        d.set_data(huge.as_str(), b"huge-key".to_vec());
1071        assert_eq!(d.get_data(&huge), Some(&b"huge-key"[..]));
1072        // A 1M-char key that differs only in the last byte must miss.
1073        let mut nearly = huge.clone();
1074        let _ = nearly.pop();
1075        nearly.push('b');
1076        assert!(d.get_data(&nearly).is_none());
1077    }
1078
1079    #[test]
1080    fn get_data_deeply_nested_brackets_do_not_stack_overflow() {
1081        // The lookup is a linear scan, not a recursive-descent parse: 10k
1082        // nested brackets must be inert.
1083        let nested: String = "[".repeat(10_000);
1084        let mut d = DragData::new();
1085        assert!(d.get_data(&nested).is_none());
1086        d.set_data(nested.as_str(), b"nested".to_vec());
1087        assert_eq!(d.get_data(&nested), Some(&b"nested"[..]));
1088    }
1089
1090    #[test]
1091    fn set_data_replaces_existing_entry_for_same_mime() {
1092        let mut d = DragData::new();
1093        d.set_data("text/plain", b"first".to_vec());
1094        d.set_data("text/plain", b"second".to_vec());
1095        assert_eq!(d.data.len(), 1, "duplicate MIME key was appended");
1096        assert_eq!(d.get_data("text/plain"), Some(&b"second"[..]));
1097    }
1098
1099    #[test]
1100    fn set_data_empty_payload_is_some_empty_not_none() {
1101        let mut d = DragData::new();
1102        d.set_data("application/octet-stream", Vec::new());
1103        // Presence and emptiness are distinguishable.
1104        assert_eq!(d.get_data("application/octet-stream"), Some(&b""[..]));
1105        assert!(d.get_data("application/octet-stream").is_some());
1106    }
1107
1108    #[test]
1109    fn set_data_huge_payload_round_trips() {
1110        let mut d = DragData::new();
1111        let payload = alloc::vec![0xABu8; 1 << 20]; // 1 MiB
1112        d.set_data("application/octet-stream", payload);
1113        let got = d.get_data("application/octet-stream").unwrap();
1114        assert_eq!(got.len(), 1 << 20);
1115        assert!(got.iter().all(|b| *b == 0xAB));
1116    }
1117
1118    #[test]
1119    fn set_data_binary_payload_is_not_utf8_validated() {
1120        let mut d = DragData::new();
1121        d.set_data("application/octet-stream", alloc::vec![0xFF, 0x00, 0xFE]);
1122        assert_eq!(d.get_data("application/octet-stream"), Some(&[0xFFu8, 0x00, 0xFE][..]));
1123    }
1124
1125    #[test]
1126    fn set_data_many_distinct_mime_types_all_retrievable() {
1127        let mut d = DragData::new();
1128        for i in 0..1_000usize {
1129            let mut key = String::from("application/x-");
1130            key.push_str(&i.to_string());
1131            d.set_data(key.as_str(), alloc::vec![(i % 251) as u8]);
1132        }
1133        assert_eq!(d.data.len(), 1_000);
1134        assert_eq!(d.get_data("application/x-0"), Some(&[0u8][..]));
1135        assert_eq!(d.get_data("application/x-999"), Some(&[(999 % 251) as u8][..]));
1136        assert!(d.get_data("application/x-1000").is_none());
1137    }
1138
1139    #[test]
1140    fn set_text_get_text_round_trip_unicode() {
1141        for s in [
1142            "hello",
1143            "",
1144            "\u{1F600}\u{1F4A9}",
1145            "e\u{0301}\u{0300}\u{0308}", // stacked combining marks
1146            "\u{200B}zero-width",
1147            "line1\nline2\r\n\ttab",
1148            "\u{0}interior nul\u{0}",
1149        ] {
1150            let mut d = DragData::new();
1151            d.set_text(s);
1152            let got = d.get_text().expect("text/plain must be present");
1153            assert_eq!(got.as_str(), s, "round-trip failed for {s:?}");
1154        }
1155    }
1156
1157    #[test]
1158    fn set_text_empty_is_some_not_none() {
1159        let mut d = DragData::new();
1160        d.set_text("");
1161        assert!(d.get_text().is_some());
1162        assert_eq!(d.get_text().unwrap().as_str(), "");
1163        assert_eq!(d.get_data("text/plain"), Some(&b""[..]));
1164    }
1165
1166    #[test]
1167    fn set_text_huge_string_round_trips() {
1168        let huge: String = "\u{1F600}".repeat(100_000); // 400_000 bytes
1169        let mut d = DragData::new();
1170        d.set_text(huge.as_str());
1171        let got = d.get_text().unwrap();
1172        assert_eq!(got.as_str().len(), 400_000);
1173        assert_eq!(got.as_str(), huge.as_str());
1174        assert_eq!(d.data.len(), 1);
1175    }
1176
1177    #[test]
1178    fn set_text_twice_replaces_and_does_not_grow() {
1179        let mut d = DragData::new();
1180        d.set_text("one");
1181        d.set_text("two");
1182        assert_eq!(d.data.len(), 1);
1183        assert_eq!(d.get_text().unwrap().as_str(), "two");
1184    }
1185
1186    #[test]
1187    fn set_text_then_set_data_on_same_mime_wins() {
1188        let mut d = DragData::new();
1189        d.set_text("text");
1190        d.set_data("text/plain", b"raw".to_vec());
1191        assert_eq!(d.data.len(), 1);
1192        assert_eq!(d.get_text().unwrap().as_str(), "raw");
1193    }
1194
1195    #[test]
1196    fn get_text_on_invalid_utf8_yields_empty_string_not_panic() {
1197        // Documents the `unwrap_or("")` fallback: invalid UTF-8 under
1198        // "text/plain" is silently reported as an EMPTY string, not None and
1199        // not a panic. (Lossy data: the bytes are still there via get_data.)
1200        let mut d = DragData::new();
1201        d.set_data("text/plain", alloc::vec![0xFF, 0xFE, 0x80]);
1202        let got = d.get_text().expect("entry exists, so Some");
1203        assert_eq!(got.as_str(), "");
1204        assert_eq!(d.get_data("text/plain"), Some(&[0xFFu8, 0xFE, 0x80][..]));
1205    }
1206
1207    #[test]
1208    fn get_text_truncated_utf8_yields_empty_string() {
1209        let mut d = DragData::new();
1210        // First 3 bytes of a 4-byte emoji.
1211        d.set_data("text/plain", alloc::vec![0xF0, 0x9F, 0x98]);
1212        assert_eq!(d.get_text().unwrap().as_str(), "");
1213    }
1214
1215    #[test]
1216    fn get_text_is_none_when_only_other_mimes_present() {
1217        let mut d = DragData::new();
1218        d.set_data("text/html", b"<b>x</b>".to_vec());
1219        assert!(d.get_text().is_none());
1220    }
1221
1222    // ==================================================== DragContext ctors
1223
1224    #[test]
1225    fn drag_context_new_preserves_session_id_and_is_not_cancelled() {
1226        for sid in [0u64, 1, u64::MAX] {
1227            let ctx = DragContext::new(
1228                ActiveDragType::WindowMove(WindowMoveDrag {
1229                    start_position: LogicalPosition::zero(),
1230                    current_position: LogicalPosition::zero(),
1231                    initial_window_position: WindowPosition::Uninitialized,
1232                }),
1233                sid,
1234            );
1235            assert_eq!(ctx.session_id, sid);
1236            assert!(!ctx.cancelled);
1237        }
1238    }
1239
1240    #[test]
1241    fn text_selection_invariants_at_extremes() {
1242        let pos = LogicalPosition::new(f32::MIN, f32::MAX);
1243        let ctx = DragContext::text_selection(
1244            dom(usize::MAX),
1245            NodeId::new(usize::MAX),
1246            pos,
1247            u64::MAX,
1248        );
1249        let ts = ctx.as_text_selection().expect("must be a text selection");
1250        assert_eq!(ts.dom_id, dom(usize::MAX));
1251        assert_eq!(ts.anchor_ifc_node, NodeId::new(usize::MAX));
1252        assert!(ts.anchor_cursor.is_none());
1253        assert!(ts.auto_scroll_container.is_none());
1254        assert_eq!(ts.auto_scroll_direction, AutoScrollDirection::None);
1255        // start == current at construction, bit-for-bit (quantized PartialEq
1256        // would happily accept a saturated mismatch here, so compare raw bits).
1257        assert_eq!(ts.start_mouse_position.x.to_bits(), f32::MIN.to_bits());
1258        assert_eq!(ts.start_mouse_position.y.to_bits(), f32::MAX.to_bits());
1259        assert_eq!(
1260            ts.current_mouse_position.x.to_bits(),
1261            ts.start_mouse_position.x.to_bits()
1262        );
1263        assert_eq!(ctx.session_id, u64::MAX);
1264    }
1265
1266    #[test]
1267    fn text_selection_with_nan_position_does_not_panic() {
1268        let ctx = DragContext::text_selection(
1269            dom(0),
1270            NodeId::ZERO,
1271            LogicalPosition::new(f32::NAN, f32::NEG_INFINITY),
1272            0,
1273        );
1274        let ts = ctx.as_text_selection().unwrap();
1275        assert!(ts.start_mouse_position.x.is_nan());
1276        assert!(ts.current_mouse_position.x.is_nan());
1277        assert_eq!(ts.start_mouse_position.y, f32::NEG_INFINITY);
1278    }
1279
1280    #[test]
1281    fn scrollbar_thumb_stores_all_float_metrics_verbatim_incl_nan_inf() {
1282        let ctx = DragContext::scrollbar_thumb(
1283            dom(3),
1284            NodeId::new(9),
1285            ScrollbarAxis::Horizontal,
1286            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
1287            f32::NAN,
1288            f32::INFINITY,
1289            -0.0,
1290            f32::MAX,
1291            0,
1292        );
1293        let sb = ctx.as_scrollbar_thumb().unwrap();
1294        assert_eq!(sb.dom_id, dom(3));
1295        assert_eq!(sb.scroll_container_node, NodeId::new(9));
1296        assert_eq!(sb.axis, ScrollbarAxis::Horizontal);
1297        assert!(sb.start_scroll_offset.is_nan(), "NaN was mangled at construction");
1298        assert_eq!(sb.track_length_px, f32::INFINITY);
1299        assert!(sb.content_length_px.is_sign_negative(), "-0.0 lost its sign");
1300        assert_eq!(sb.content_length_px, 0.0);
1301        assert_eq!(sb.viewport_length_px, f32::MAX);
1302        assert_eq!(sb.current_mouse_position.x, f32::INFINITY);
1303    }
1304
1305    #[test]
1306    fn scrollbar_thumb_all_zero_is_constructible() {
1307        let ctx = vscroll(0.0, 0.0, 0.0, 0.0);
1308        assert!(ctx.is_scrollbar_thumb());
1309        let sb = ctx.as_scrollbar_thumb().unwrap();
1310        assert_eq!(sb.start_scroll_offset, 0.0);
1311        assert_eq!(sb.track_length_px, 0.0);
1312    }
1313
1314    #[test]
1315    fn node_drag_invariants_hold_after_construction() {
1316        let mut data = DragData::new();
1317        data.set_text("payload");
1318        let ctx = DragContext::node_drag(
1319            dom(2),
1320            NodeId::new(usize::MAX),
1321            LogicalPosition::new(-1.5, 2.5),
1322            data,
1323            u64::MAX,
1324        );
1325        let nd = ctx.as_node_drag().unwrap();
1326        assert_eq!(nd.dom_id, dom(2));
1327        assert_eq!(nd.node_id, NodeId::new(usize::MAX));
1328        assert_eq!(nd.start_position, nd.current_position);
1329        assert_eq!(nd.drag_offset, LogicalPosition::zero());
1330        assert!(nd.current_drop_target.into_option().is_none());
1331        assert!(nd.previous_drop_target.into_option().is_none());
1332        assert!(!nd.drop_accepted);
1333        assert_eq!(nd.drop_effect, DropEffect::None);
1334        assert_eq!(nd.drag_data.get_text().unwrap().as_str(), "payload");
1335    }
1336
1337    #[test]
1338    fn node_drag_with_empty_drag_data_is_fine() {
1339        let ctx = DragContext::node_drag(
1340            dom(0),
1341            NodeId::ZERO,
1342            LogicalPosition::new(f32::NAN, f32::NAN),
1343            DragData::new(),
1344            0,
1345        );
1346        let nd = ctx.as_node_drag().unwrap();
1347        assert!(nd.drag_data.data.is_empty());
1348        assert!(nd.start_position.x.is_nan());
1349        assert!(nd.current_position.x.is_nan());
1350    }
1351
1352    #[test]
1353    fn window_move_preserves_initial_window_position_extremes() {
1354        for wp in [
1355            WindowPosition::Uninitialized,
1356            WindowPosition::Initialized(PhysicalPosition {
1357                x: i32::MIN,
1358                y: i32::MAX,
1359            }),
1360            WindowPosition::Initialized(PhysicalPosition { x: 0, y: 0 }),
1361        ] {
1362            let ctx = DragContext::window_move(
1363                LogicalPosition::new(f32::MAX, f32::MIN),
1364                wp,
1365                u64::MAX,
1366            );
1367            let wm = ctx.as_window_move().unwrap();
1368            assert_eq!(wm.initial_window_position, wp);
1369            assert_eq!(wm.start_position.x.to_bits(), f32::MAX.to_bits());
1370            assert_eq!(
1371                wm.current_position.y.to_bits(),
1372                wm.start_position.y.to_bits()
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn file_drop_empty_file_list_is_allowed() {
1379        let ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
1380        let fd = ctx.as_file_drop().unwrap();
1381        assert_eq!(fd.files.len(), 0);
1382        assert!(fd.drop_target.into_option().is_none());
1383        assert_eq!(fd.drop_effect, DropEffect::Copy);
1384    }
1385
1386    #[test]
1387    fn file_drop_unicode_and_pathological_filenames_round_trip() {
1388        let files = alloc::vec![
1389            AzString::from(""),
1390            AzString::from("/tmp/\u{1F4C1}/f\u{0301}ile.txt"),
1391            AzString::from("C:\\Windows\\..\\..\\etc\\passwd"),
1392            AzString::from("a".repeat(4096)),
1393            AzString::from("with\nnewline\tand\u{0}nul"),
1394        ];
1395        let ctx = DragContext::file_drop(files, LogicalPosition::new(1.0, 2.0), 42);
1396        let fd = ctx.as_file_drop().unwrap();
1397        assert_eq!(fd.files.len(), 5);
1398        assert_eq!(fd.files.as_slice()[0].as_str(), "");
1399        assert_eq!(fd.files.as_slice()[3].as_str().len(), 4096);
1400        assert_eq!(fd.files.as_slice()[4].as_str(), "with\nnewline\tand\u{0}nul");
1401        assert_eq!(ctx.session_id, 42);
1402    }
1403
1404    #[test]
1405    fn file_drop_ten_thousand_files_does_not_hang() {
1406        let mut files = Vec::with_capacity(10_000);
1407        for i in 0..10_000usize {
1408            files.push(AzString::from(i.to_string()));
1409        }
1410        let ctx = DragContext::file_drop(files, LogicalPosition::zero(), 1);
1411        assert_eq!(ctx.as_file_drop().unwrap().files.len(), 10_000);
1412    }
1413
1414    // ================================================ predicates / accessors
1415
1416    fn one_of_each() -> [DragContext; 6] {
1417        [
1418            DragContext::text_selection(dom(0), NodeId::ZERO, LogicalPosition::zero(), 0),
1419            vscroll(0.0, 100.0, 200.0, 100.0),
1420            DragContext::node_drag(
1421                dom(0),
1422                NodeId::ZERO,
1423                LogicalPosition::zero(),
1424                DragData::new(),
1425                0,
1426            ),
1427            DragContext::window_move(
1428                LogicalPosition::zero(),
1429                WindowPosition::Uninitialized,
1430                0,
1431            ),
1432            resize_ctx(),
1433            DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0),
1434        ]
1435    }
1436
1437    #[test]
1438    fn predicates_are_mutually_exclusive_across_every_variant() {
1439        for (i, ctx) in one_of_each().iter().enumerate() {
1440            let flags = [
1441                ctx.is_text_selection(),
1442                ctx.is_scrollbar_thumb(),
1443                ctx.is_node_drag(),
1444                ctx.is_window_move(),
1445                ctx.is_file_drop(),
1446            ];
1447            let set = flags.iter().filter(|f| **f).count();
1448            if i == 4 {
1449                // WindowResize has no predicate: every is_* must be false.
1450                assert_eq!(set, 0, "WindowResize matched a predicate");
1451            } else {
1452                assert_eq!(set, 1, "variant {i} matched {set} predicates, expected 1");
1453                assert!(flags[if i == 5 { 4 } else { i }], "wrong predicate for {i}");
1454            }
1455        }
1456    }
1457
1458    #[test]
1459    fn as_accessors_return_none_for_every_non_matching_variant() {
1460        for (i, ctx) in one_of_each().iter().enumerate() {
1461            assert_eq!(ctx.as_text_selection().is_some(), i == 0);
1462            assert_eq!(ctx.as_scrollbar_thumb().is_some(), i == 1);
1463            assert_eq!(ctx.as_node_drag().is_some(), i == 2);
1464            assert_eq!(ctx.as_window_move().is_some(), i == 3);
1465            assert_eq!(ctx.as_file_drop().is_some(), i == 5);
1466        }
1467    }
1468
1469    #[test]
1470    fn as_mut_accessors_return_none_for_every_non_matching_variant() {
1471        for (i, ctx) in one_of_each().iter_mut().enumerate() {
1472            assert_eq!(ctx.as_text_selection_mut().is_some(), i == 0);
1473            assert_eq!(ctx.as_scrollbar_thumb_mut().is_some(), i == 1);
1474            assert_eq!(ctx.as_node_drag_mut().is_some(), i == 2);
1475            assert_eq!(ctx.as_file_drop_mut().is_some(), i == 5);
1476        }
1477    }
1478
1479    #[test]
1480    fn as_text_selection_mut_writes_are_visible_through_shared_getter() {
1481        let mut ctx =
1482            DragContext::text_selection(dom(0), NodeId::new(1), LogicalPosition::zero(), 0);
1483        {
1484            let ts = ctx.as_text_selection_mut().unwrap();
1485            ts.auto_scroll_direction = AutoScrollDirection::DownRight;
1486            ts.auto_scroll_container = Some(NodeId::new(77));
1487        }
1488        let ts = ctx.as_text_selection().unwrap();
1489        assert_eq!(ts.auto_scroll_direction, AutoScrollDirection::DownRight);
1490        assert_eq!(ts.auto_scroll_container, Some(NodeId::new(77)));
1491    }
1492
1493    #[test]
1494    fn as_scrollbar_thumb_mut_writes_are_visible_through_shared_getter() {
1495        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1496        ctx.as_scrollbar_thumb_mut().unwrap().start_scroll_offset = f32::NAN;
1497        assert!(ctx.as_scrollbar_thumb().unwrap().start_scroll_offset.is_nan());
1498    }
1499
1500    #[test]
1501    fn as_node_drag_mut_writes_are_visible_through_shared_getter() {
1502        let mut ctx = DragContext::node_drag(
1503            dom(0),
1504            NodeId::ZERO,
1505            LogicalPosition::zero(),
1506            DragData::new(),
1507            0,
1508        );
1509        {
1510            let nd = ctx.as_node_drag_mut().unwrap();
1511            nd.drop_accepted = true;
1512            nd.drop_effect = DropEffect::Move;
1513            nd.current_drop_target = Some(dnid(0, 4)).into();
1514        }
1515        let nd = ctx.as_node_drag().unwrap();
1516        assert!(nd.drop_accepted);
1517        assert_eq!(nd.drop_effect, DropEffect::Move);
1518        assert_eq!(
1519            nd.current_drop_target
1520                .into_option()
1521                .unwrap()
1522                .node
1523                .into_crate_internal(),
1524            Some(NodeId::new(4))
1525        );
1526    }
1527
1528    #[test]
1529    fn as_file_drop_mut_writes_are_visible_through_shared_getter() {
1530        let mut ctx = DragContext::file_drop(Vec::new(), LogicalPosition::zero(), 0);
1531        ctx.as_file_drop_mut().unwrap().drop_effect = DropEffect::Link;
1532        assert_eq!(ctx.as_file_drop().unwrap().drop_effect, DropEffect::Link);
1533    }
1534
1535    // ============================================== update / position getters
1536
1537    #[test]
1538    fn update_position_moves_current_for_every_variant_and_leaves_start_alone() {
1539        let new_pos = LogicalPosition::new(123.5, -456.25);
1540        for (i, mut ctx) in one_of_each().into_iter().enumerate() {
1541            let start_before = ctx.start_position();
1542            ctx.update_position(new_pos);
1543            assert_eq!(ctx.current_position(), new_pos, "variant {i} did not move");
1544            if i == 5 {
1545                // FileDrop has a single `position` field: start aliases current,
1546                // so updating the position also moves the reported start.
1547                assert_eq!(ctx.start_position(), new_pos);
1548            } else {
1549                assert_eq!(ctx.start_position(), start_before, "variant {i} start moved");
1550            }
1551        }
1552    }
1553
1554    #[test]
1555    fn update_position_with_nan_and_inf_is_stored_verbatim() {
1556        for mut ctx in one_of_each() {
1557            ctx.update_position(LogicalPosition::new(f32::NAN, f32::INFINITY));
1558            let cur = ctx.current_position();
1559            assert!(cur.x.is_nan());
1560            assert_eq!(cur.y, f32::INFINITY);
1561
1562            ctx.update_position(LogicalPosition::new(f32::MIN, f32::NEG_INFINITY));
1563            let cur = ctx.current_position();
1564            assert_eq!(cur.x.to_bits(), f32::MIN.to_bits());
1565            assert_eq!(cur.y, f32::NEG_INFINITY);
1566        }
1567    }
1568
1569    #[test]
1570    fn update_position_is_idempotent_and_last_write_wins() {
1571        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1572        for i in 0..1000u32 {
1573            ctx.update_position(LogicalPosition::new(i as f32, -(i as f32)));
1574        }
1575        assert_eq!(ctx.current_position(), LogicalPosition::new(999.0, -999.0));
1576        assert_eq!(ctx.start_position(), LogicalPosition::zero());
1577    }
1578
1579    #[test]
1580    fn window_resize_position_getters_work_without_a_predicate() {
1581        let mut ctx = resize_ctx();
1582        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
1583        assert_eq!(ctx.current_position(), LogicalPosition::new(1.0, 2.0));
1584        ctx.update_position(LogicalPosition::new(-3.0, -4.0));
1585        assert_eq!(ctx.current_position(), LogicalPosition::new(-3.0, -4.0));
1586        assert_eq!(ctx.start_position(), LogicalPosition::new(1.0, 2.0));
1587        // No as_window_resize() accessor exists; the others must all say None.
1588        assert!(ctx.as_text_selection().is_none());
1589        assert!(ctx.as_window_move().is_none());
1590    }
1591
1592    // ===================================== calculate_scrollbar_scroll_offset
1593
1594    #[test]
1595    fn scroll_offset_is_none_for_non_scrollbar_drags() {
1596        for (i, ctx) in one_of_each().iter().enumerate() {
1597            if i == 1 {
1598                continue;
1599            }
1600            assert!(
1601                ctx.calculate_scrollbar_scroll_offset().is_none(),
1602                "variant {i} returned Some"
1603            );
1604        }
1605    }
1606
1607    #[test]
1608    fn scroll_offset_basic_vertical_half_track() {
1609        // track=100, content=200, viewport=100 => range=100, thumb=50,
1610        // scrollable_track=50. A 25px drag is half the scrollable track =>
1611        // half the range = 50.
1612        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1613        ctx.update_position(LogicalPosition::new(0.0, 25.0));
1614        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
1615    }
1616
1617    #[test]
1618    fn scroll_offset_horizontal_uses_x_and_ignores_y() {
1619        let mut ctx = DragContext::scrollbar_thumb(
1620            dom(0),
1621            NodeId::new(1),
1622            ScrollbarAxis::Horizontal,
1623            LogicalPosition::zero(),
1624            0.0,
1625            100.0,
1626            200.0,
1627            100.0,
1628            0,
1629        );
1630        // Pure vertical movement must not scroll a horizontal scrollbar.
1631        ctx.update_position(LogicalPosition::new(0.0, 9999.0));
1632        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1633        ctx.update_position(LogicalPosition::new(25.0, 9999.0));
1634        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(50.0));
1635    }
1636
1637    #[test]
1638    fn scroll_offset_clamps_to_range_on_huge_and_infinite_drags() {
1639        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0); // range = 100
1640        for (y, expect) in [
1641            (1.0e30f32, 100.0f32),
1642            (f32::MAX, 100.0),
1643            (f32::INFINITY, 100.0),
1644            (-1.0e30, 0.0),
1645            (f32::MIN, 0.0),
1646            (f32::NEG_INFINITY, 0.0),
1647        ] {
1648            ctx.update_position(LogicalPosition::new(0.0, y));
1649            assert_eq!(
1650                ctx.calculate_scrollbar_scroll_offset(),
1651                Some(expect),
1652                "y = {y}"
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn scroll_offset_result_always_within_range_for_finite_inputs() {
1659        let mut ctx = vscroll(30.0, 80.0, 500.0, 120.0);
1660        let range = 500.0 - 120.0;
1661        for y in [-1e9f32, -1.0, 0.0, 0.5, 1.0, 37.0, 1e9] {
1662            ctx.update_position(LogicalPosition::new(0.0, y));
1663            let off = ctx.calculate_scrollbar_scroll_offset().unwrap();
1664            assert!(
1665                (0.0..=range).contains(&off),
1666                "offset {off} escaped [0, {range}] for y = {y}"
1667            );
1668        }
1669    }
1670
1671    #[test]
1672    fn scroll_offset_out_of_range_start_offset_is_clamped_back_in() {
1673        // Even with zero mouse movement, a bogus start offset must be clamped.
1674        let ctx = vscroll(9999.0, 100.0, 200.0, 100.0);
1675        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(100.0));
1676        let ctx = vscroll(-9999.0, 100.0, 200.0, 100.0);
1677        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(0.0));
1678    }
1679
1680    #[test]
1681    fn scroll_offset_non_scrollable_content_returns_start_offset_unchanged() {
1682        // content <= viewport => nothing to scroll; the (possibly out-of-range)
1683        // start offset is returned verbatim, WITHOUT clamping.
1684        let mut ctx = vscroll(7.0, 100.0, 50.0, 100.0);
1685        ctx.update_position(LogicalPosition::new(0.0, 1000.0));
1686        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1687
1688        let ctx = vscroll(7.0, 100.0, 100.0, 100.0); // range == 0
1689        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1690
1691        let ctx = vscroll(7.0, 100.0, 0.0, 0.0); // all zero metrics
1692        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(7.0));
1693    }
1694
1695    #[test]
1696    fn scroll_offset_zero_or_negative_track_returns_start_offset() {
1697        for track in [0.0f32, -1.0, -1e30, f32::NEG_INFINITY] {
1698            let mut ctx = vscroll(7.0, track, 200.0, 100.0);
1699            ctx.update_position(LogicalPosition::new(0.0, 50.0));
1700            assert_eq!(
1701                ctx.calculate_scrollbar_scroll_offset(),
1702                Some(7.0),
1703                "track = {track}"
1704            );
1705        }
1706    }
1707
1708    #[test]
1709    fn scroll_offset_negative_content_and_viewport_return_start_offset() {
1710        let mut ctx = vscroll(3.0, 100.0, -200.0, -100.0);
1711        ctx.update_position(LogicalPosition::new(0.0, 50.0));
1712        // range = -200 - (-100) = -100 <= 0 => early out.
1713        assert_eq!(ctx.calculate_scrollbar_scroll_offset(), Some(3.0));
1714    }
1715
1716    #[test]
1717    fn scroll_offset_nan_start_offset_propagates_nan_without_panicking() {
1718        let mut ctx = vscroll(f32::NAN, 100.0, 200.0, 100.0);
1719        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1720        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1721        // NaN start offset is neither clamped nor rejected: it leaks through.
1722        assert!(off.is_nan());
1723    }
1724
1725    #[test]
1726    fn scroll_offset_nan_track_length_propagates_nan_without_panicking() {
1727        let mut ctx = vscroll(0.0, f32::NAN, 200.0, 100.0);
1728        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1729        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1730        // NaN track passes the `track_length_px <= 0.0` guard (NaN compares
1731        // false) and poisons the result. min/max of the clamp stay finite, so
1732        // no panic — just a NaN scroll offset handed to the caller.
1733        assert!(off.is_nan());
1734    }
1735
1736    #[test]
1737    fn scroll_offset_nan_mouse_position_propagates_nan_without_panicking() {
1738        let mut ctx = vscroll(0.0, 100.0, 200.0, 100.0);
1739        ctx.update_position(LogicalPosition::new(0.0, f32::NAN));
1740        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1741        assert!(off.is_nan());
1742    }
1743
1744    #[test]
1745    fn scroll_offset_infinite_content_yields_nan_on_zero_mouse_delta() {
1746        // range = inf, thumb = 0, ratio = 0 => scroll_delta = 0.0 * inf = NaN.
1747        // Not a panic (clamp's min/max are 0.0/inf), but the returned offset is
1748        // NaN even though the mouse never moved.
1749        let ctx = vscroll(0.0, 100.0, f32::INFINITY, 100.0);
1750        let off = ctx.calculate_scrollbar_scroll_offset().expect("Some");
1751        assert!(off.is_nan(), "expected the 0 * inf NaN, got {off}");
1752    }
1753
1754    // BUG: f32::clamp(min, max) asserts `min <= max`, and every NaN comparison
1755    // is false — so a NaN `scrollable_range` (from a NaN or inf/inf content or
1756    // viewport metric) makes `new_offset.clamp(0.0, scrollable_range)` PANIC
1757    // inside a getter. A layout that produced a NaN content length would take
1758    // the whole app down on the next scrollbar drag event. The guards at the
1759    // top of the function do not catch it because `NaN <= 0.0` is false.
1760    #[test]
1761    #[should_panic]
1762    fn scroll_offset_nan_content_length_panics_in_clamp() {
1763        let mut ctx = vscroll(0.0, 100.0, f32::NAN, 100.0);
1764        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1765        let _ = ctx.calculate_scrollbar_scroll_offset();
1766    }
1767
1768    #[test]
1769    #[should_panic]
1770    fn scroll_offset_nan_viewport_length_panics_in_clamp() {
1771        let mut ctx = vscroll(0.0, 100.0, 200.0, f32::NAN);
1772        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1773        let _ = ctx.calculate_scrollbar_scroll_offset();
1774    }
1775
1776    #[test]
1777    #[should_panic]
1778    fn scroll_offset_infinite_content_and_viewport_panics_in_clamp() {
1779        // inf - inf = NaN scrollable_range => same clamp assertion.
1780        let mut ctx = vscroll(0.0, 100.0, f32::INFINITY, f32::INFINITY);
1781        ctx.update_position(LogicalPosition::new(0.0, 10.0));
1782        let _ = ctx.calculate_scrollbar_scroll_offset();
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}