Skip to main content

repose_core/
dnd.rs

1use crate::Vec2;
2use crate::color::{Brush, Color};
3use crate::geometry::Rect;
4use crate::input::{Modifiers, PointerKind};
5use crate::runtime::{Frame, HitRegion};
6use crate::shortcuts::DragAction;
7use crate::text::FontWeight;
8use crate::view::{Scene, SceneNode, TextPaintStyle};
9use std::cell::RefCell;
10use std::{any::Any, path::PathBuf, rc::Rc, sync::Arc};
11use web_time::Instant;
12
13/// Opaque payload moved during internal drag & drop.
14/// Use [`downcast_drag_payload`] on the receiver side to recover a typed value.
15pub type DragPayload = Rc<dyn Any>;
16
17/// Wrap a typed value into a [`DragPayload`] for a drag source.
18///
19/// ```ignore
20/// Modifier::new().on_drag_start(|_start| Some(drag_payload(MyItem { id: 1 })))
21/// ```
22pub fn drag_payload<T: 'static>(value: T) -> DragPayload {
23    Rc::new(value)
24}
25
26/// Try to downcast a drag payload to a typed reference. Used on the drop side.
27///
28/// ```ignore
29/// if let Some(item) = downcast_drag_payload::<MyItem>(&ev.payload) {
30///     // handle item
31/// }
32/// ```
33pub fn downcast_drag_payload<T: 'static>(payload: &DragPayload) -> Option<&T> {
34    payload.as_ref().downcast_ref::<T>()
35}
36
37/// Block-style convenience for [`Modifier::on_drag_start`] with a typed payload.
38///
39/// ```ignore
40/// use repose_core::{Modifier, drag_and_drop_source};
41/// struct MyItem { id: i32 }
42/// let m = drag_and_drop_source(Modifier::new(), |_start| Some(MyItem { id: 1 }));
43/// ```
44///
45/// is equivalent to:
46///
47/// ```ignore
48/// Modifier::new().on_drag_start(|_start| Some(drag_payload(MyItem { id: 1 })))
49/// ```
50pub fn drag_and_drop_source<T, F>(mut modifier: crate::Modifier, on_start: F) -> crate::Modifier
51where
52    T: 'static,
53    F: Fn(DragStart) -> Option<T> + 'static,
54{
55    modifier = modifier.on_drag_start(move |start| on_start(start).map(drag_payload::<T>));
56    modifier
57}
58
59/// Typed source + custom drag decoration in one call.
60pub fn drag_and_drop_source_with_preview<T, F>(
61    mut modifier: crate::Modifier,
62    preview: DragPreview,
63    on_start: F,
64) -> crate::Modifier
65where
66    T: 'static,
67    F: Fn(DragStart) -> Option<T> + 'static,
68{
69    modifier = modifier
70        .draw_drag_decoration_rc(preview)
71        .on_drag_start(move |start| on_start(start).map(drag_payload::<T>));
72    modifier
73}
74
75/// Block-style convenience for [`Modifier::on_drop`] with a typed payload. The
76/// drop is accepted when the closure returns `true`. The typed payload is
77/// downcast before the closure is invoked.
78///
79/// ```ignore
80/// use repose_core::{Modifier, drag_and_drop_target};
81/// struct MyItem { id: i32 }
82/// let m = drag_and_drop_target(Modifier::new(), |_ev, item: &MyItem| {
83///     println!("got id {}", item.id);
84///     true
85/// });
86/// ```
87pub fn drag_and_drop_target<T, F>(mut modifier: crate::Modifier, on_drop: F) -> crate::Modifier
88where
89    T: 'static,
90    F: Fn(&DropEvent, &T) -> bool + 'static,
91{
92    modifier = modifier.on_drop(move |ev| match downcast_drag_payload::<T>(&ev.payload) {
93        Some(v) => on_drop(&ev, v),
94        None => false,
95    });
96    modifier
97}
98
99/// Compose-like typed drag/drop modifier helpers.
100pub trait DragDropModifierExt: Sized {
101    /// Make this node a typed drag source.
102    fn drag_source<T>(
103        self,
104        make_payload: impl Fn(DragStart) -> Option<T> + 'static,
105    ) -> crate::Modifier
106    where
107        T: 'static;
108
109    /// Typed drag-enter.
110    fn on_drag_enter_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
111    where
112        T: 'static;
113
114    /// Typed drag-over.
115    fn on_drag_over_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
116    where
117        T: 'static;
118
119    /// Typed drag-leave.
120    fn on_drag_leave_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
121    where
122        T: 'static;
123
124    /// Typed drop target.
125    fn on_drop_typed<T>(self, f: impl Fn(&DropEvent, &T) -> bool + 'static) -> crate::Modifier
126    where
127        T: 'static;
128
129    /// Common case: typed drag-over + typed drop.
130    fn drop_target<T>(self, on_drop: impl Fn(&DropEvent, &T) -> bool + 'static) -> crate::Modifier
131    where
132        T: 'static;
133}
134
135impl DragDropModifierExt for crate::Modifier {
136    fn drag_source<T>(
137        self,
138        make_payload: impl Fn(DragStart) -> Option<T> + 'static,
139    ) -> crate::Modifier
140    where
141        T: 'static,
142    {
143        self.on_drag_start(move |start| make_payload(start).map(drag_payload::<T>))
144    }
145
146    fn on_drag_enter_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
147    where
148        T: 'static,
149    {
150        self.on_drag_enter(move |ev| {
151            if let Some(payload) = downcast_drag_payload::<T>(&ev.payload) {
152                f(&ev, payload);
153            }
154        })
155    }
156
157    fn on_drag_over_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
158    where
159        T: 'static,
160    {
161        self.on_drag_over(move |ev| {
162            if let Some(payload) = downcast_drag_payload::<T>(&ev.payload) {
163                f(&ev, payload);
164            }
165        })
166    }
167
168    fn on_drag_leave_typed<T>(self, f: impl Fn(&DragOver, &T) + 'static) -> crate::Modifier
169    where
170        T: 'static,
171    {
172        self.on_drag_leave(move |ev| {
173            if let Some(payload) = downcast_drag_payload::<T>(&ev.payload) {
174                f(&ev, payload);
175            }
176        })
177    }
178
179    fn on_drop_typed<T>(self, f: impl Fn(&DropEvent, &T) -> bool + 'static) -> crate::Modifier
180    where
181        T: 'static,
182    {
183        self.on_drop(move |ev| {
184            let Some(payload) = downcast_drag_payload::<T>(&ev.payload) else {
185                return false;
186            };
187            f(&ev, payload)
188        })
189    }
190
191    fn drop_target<T>(self, on_drop: impl Fn(&DropEvent, &T) -> bool + 'static) -> crate::Modifier
192    where
193        T: 'static,
194    {
195        self.on_drop_typed(on_drop)
196    }
197}
198
199#[derive(Clone, Debug)]
200pub struct DragStart {
201    pub source_id: u64,
202    pub position: Vec2,
203    pub modifiers: Modifiers,
204}
205
206#[derive(Clone, Debug)]
207pub struct DragOver {
208    pub source_id: u64,
209    pub target_id: u64,
210    pub position: Vec2,
211    pub modifiers: Modifiers,
212    pub payload: DragPayload,
213}
214
215#[derive(Clone, Debug)]
216pub struct DropEvent {
217    pub source_id: u64,
218    pub target_id: u64,
219    pub position: Vec2,
220    pub modifiers: Modifiers,
221    pub payload: DragPayload,
222}
223
224/// Sent to the drag source when the drag ends (drop or cancel).
225#[derive(Clone, Copy, Debug)]
226pub struct DragEnd {
227    pub accepted: bool,
228}
229
230/// Context passed to a drag preview painter each frame while dragging.
231#[derive(Clone)]
232pub struct DragPreviewCtx {
233    /// Current pointer position (px).
234    pub pointer: Vec2,
235    /// Pointer position when the drag started (px).
236    pub start_pointer: Vec2,
237    /// Source hit-region rect at drag start (px).
238    pub source_rect: Rect,
239    /// `pointer_at_start - source_rect.origin` so the ghost sticks under the finger/cursor.
240    pub grab_offset: Vec2,
241    /// Active payload (clone of session payload).
242    pub payload: DragPayload,
243}
244
245/// Painter for the floating drag decoration.
246/// Coordinates are in **screen px**. Draw relative to `ctx.pointer` / `ctx.grab_offset`.
247pub type DragPreview = Rc<dyn Fn(&mut Scene, &DragPreviewCtx)>;
248
249/// Call from `on_drag_start` to supply a session-specific preview.
250///
251/// ```ignore
252/// .on_drag_start(move |_s| {
253///     provide_drag_preview(drag_preview_label(title.clone(), Color::from_hex("#44AAFF")));
254///     Some(drag_payload(MyItem { .. }))
255/// })
256/// ```
257pub fn provide_drag_preview(preview: DragPreview) {
258    with_dnd_state(|state| state.pending_preview = Some(preview));
259}
260
261fn take_pending_preview() -> Option<DragPreview> {
262    with_dnd_state(|state| state.pending_preview.take())
263}
264
265/// Simple floating label chip (good default for tabs / list rows).
266pub fn drag_preview_label(label: impl Into<String>, accent: Color) -> DragPreview {
267    let label = label.into();
268    Rc::new(move |scene: &mut Scene, ctx: &DragPreviewCtx| {
269        draw_label_chip(scene, ctx.pointer, &label, accent, false);
270    })
271}
272
273/// Label chip with a slightly larger "elevated" look (shadow-ish fill).
274pub fn drag_preview_chip(label: impl Into<String>, accent: Color) -> DragPreview {
275    let label = label.into();
276    Rc::new(move |scene: &mut Scene, ctx: &DragPreviewCtx| {
277        draw_label_chip(scene, ctx.pointer, &label, accent, true);
278    })
279}
280
281fn draw_label_chip(scene: &mut Scene, pointer: Vec2, label: &str, accent: Color, elevated: bool) {
282    use crate::units::{Px, UnitExt as _};
283    // Paint-space: Dp/Sp converted at the boundary (Compose `Density.toPx`).
284    // `Rect` carries px magnitudes; scalar node fields take `Px`.
285    let pad_x = 10.0.dp().to_px();
286    let pad_y = 6.0.dp().to_px();
287    let font_px = 13.0.sp().to_px();
288    // Approximate width: ~0.55em per char (good enough without measuring).
289    let text_w = Px((label.chars().count() as f32 * font_px.0 * 0.55).max(24.0.dp().to_px().0));
290    let w = text_w.0 + pad_x.0 * 2.0;
291    let h = font_px.0 + pad_y.0 * 2.0;
292    let r = 8.0.dp().to_px();
293
294    let origin = Vec2 {
295        x: pointer.x + 14.0.dp().to_px().0,
296        y: pointer.y + 14.0.dp().to_px().0,
297    };
298    let rect = Rect {
299        x: origin.x,
300        y: origin.y,
301        w,
302        h,
303    };
304
305    if elevated {
306        // Soft "shadow"
307        scene.nodes.push(SceneNode::Rect {
308            rect: Rect {
309                x: rect.x + 2.0.dp().to_px().0,
310                y: rect.y + 3.0.dp().to_px().0,
311                w: rect.w,
312                h: rect.h,
313            },
314            brush: Brush::Solid(Color::from_rgba(0, 0, 0, 50)),
315            radius: [r; 4],
316        });
317    }
318
319    let bg = accent.with_alpha(0xDD);
320    scene.nodes.push(SceneNode::Rect {
321        rect,
322        brush: Brush::Solid(bg),
323        radius: [r; 4],
324    });
325    scene.nodes.push(SceneNode::Border {
326        rect,
327        brush: Brush::Solid(accent.with_alpha(0xFF)),
328        width: 1.0.dp().to_px(),
329        radius: [r; 4],
330    });
331    scene.nodes.push(SceneNode::Text {
332        rect: Rect {
333            x: rect.x + pad_x.0,
334            y: rect.y + pad_y.0,
335            w: text_w.0,
336            h: font_px.0,
337        },
338        text: Arc::<str>::from(label),
339        color: Color::WHITE,
340        size: font_px,
341        style: TextPaintStyle {
342            font_weight: FontWeight::MEDIUM,
343            ..Default::default()
344        },
345    });
346}
347
348/// Default ghost: translucent clone of the source bounds, locked to grab offset.
349fn draw_default_source_ghost(scene: &mut Scene, ctx: &DragPreviewCtx, accent: Color) {
350    use crate::units::UnitExt as _;
351    let w = ctx.source_rect.w.max(24.0.dp().to_px().0);
352    let h = ctx.source_rect.h.max(16.0.dp().to_px().0);
353    let rect = Rect {
354        x: ctx.pointer.x - ctx.grab_offset.x,
355        y: ctx.pointer.y - ctx.grab_offset.y,
356        w,
357        h,
358    };
359    let r = 6.0.dp().to_px();
360    scene.nodes.push(SceneNode::Rect {
361        rect,
362        brush: Brush::Solid(accent.with_alpha(0x55)),
363        radius: [r; 4],
364    });
365    scene.nodes.push(SceneNode::Border {
366        rect,
367        brush: Brush::Solid(accent.with_alpha(0xCC)),
368        width: 1.5.dp().to_px(),
369        radius: [r; 4],
370    });
371}
372
373/// A single dropped file descriptor.
374/// - On desktop: `path` is `Some(PathBuf)`.
375/// - On web: `path` is usually `None` (browser doesn't expose local paths).
376#[derive(Clone, Debug)]
377pub struct DroppedFile {
378    pub name: String,
379    pub path: Option<PathBuf>,
380}
381
382/// Payload type for file drag/drop coming from the OS/browser.
383#[derive(Clone, Debug)]
384pub struct DroppedFiles {
385    pub files: Vec<DroppedFile>,
386}
387
388/// Tracks an active drag session (internal widget-to-widget DnD).
389#[derive(Clone)]
390pub struct DragSession {
391    pub source_id: u64,
392    pub payload: DragPayload,
393    pub start_px: (f32, f32),
394    pub over_id: Option<u64>,
395    /// Source hit rect at drag start (px).
396    pub source_rect: Rect,
397    /// Pointer - source origin at drag start.
398    pub grab_offset: Vec2,
399    /// Optional custom decoration (Compose `drawDragDecoration`).
400    pub preview: Option<DragPreview>,
401}
402
403// Manual Debug (Rc<dyn Fn> is not Debug)
404impl std::fmt::Debug for DragSession {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("DragSession")
407            .field("source_id", &self.source_id)
408            .field("start_px", &self.start_px)
409            .field("over_id", &self.over_id)
410            .field("source_rect", &self.source_rect)
411            .field("grab_offset", &self.grab_offset)
412            .field("preview", &self.preview.as_ref().map(|_| "…"))
413            .finish_non_exhaustive()
414    }
415}
416
417#[derive(Clone)]
418struct MouseDownState {
419    position: Vec2,
420    capture_id: u64,
421}
422
423#[derive(Clone)]
424struct TouchDownState {
425    time: Instant,
426    position: Vec2,
427    capture_id: u64,
428    long_press_pending: bool,
429}
430
431const LONG_PRESS_MS: u128 = 400;
432
433#[derive(Default)]
434struct DndStorage {
435    frame: Option<Rc<Frame>>,
436    scale: f32,
437    session: Option<DragSession>,
438    mouse_down: Option<MouseDownState>,
439    touch_down: Option<TouchDownState>,
440    pending_preview: Option<DragPreview>,
441}
442
443#[derive(Clone)]
444pub struct DndContext {
445    storage: Rc<RefCell<DndStorage>>,
446}
447
448impl Default for DndContext {
449    fn default() -> Self {
450        Self {
451            storage: Rc::new(RefCell::new(DndStorage {
452                scale: 1.0,
453                ..DndStorage::default()
454            })),
455        }
456    }
457}
458
459pub struct DndContextGuard {
460    previous: Option<DndContext>,
461}
462
463impl DndContext {
464    pub fn enter(&self) -> DndContextGuard {
465        let previous = ACTIVE_DND_CONTEXT.with(|slot| slot.borrow_mut().replace(self.clone()));
466        DndContextGuard { previous }
467    }
468}
469
470impl Drop for DndContextGuard {
471    fn drop(&mut self) {
472        ACTIVE_DND_CONTEXT.with(|slot| {
473            *slot.borrow_mut() = self.previous.take();
474        });
475    }
476}
477
478thread_local! {
479    static GLOBAL_DND: RefCell<DndStorage> = RefCell::new(DndStorage {
480        scale: 1.0,
481        ..DndStorage::default()
482    });
483    static ACTIVE_DND_CONTEXT: RefCell<Option<DndContext>> = const { RefCell::new(None) };
484}
485
486fn with_dnd_state<R>(f: impl FnOnce(&mut DndStorage) -> R) -> R {
487    if let Some(context) = ACTIVE_DND_CONTEXT.with(|slot| slot.borrow().clone()) {
488        let mut storage = context.storage.borrow_mut();
489        f(&mut storage)
490    } else {
491        GLOBAL_DND.with(|storage| f(&mut storage.borrow_mut()))
492    }
493}
494
495fn dnd_frame() -> Option<Rc<Frame>> {
496    with_dnd_state(|state| state.frame.clone())
497}
498
499fn dnd_scale() -> f32 {
500    with_dnd_state(|state| state.scale)
501}
502
503fn mouse_down() -> Option<MouseDownState> {
504    with_dnd_state(|state| state.mouse_down.clone())
505}
506
507fn set_mouse_down(state: Option<MouseDownState>) {
508    with_dnd_state(|storage| storage.mouse_down = state);
509}
510
511fn touch_down() -> Option<TouchDownState> {
512    with_dnd_state(|state| state.touch_down.clone())
513}
514
515fn set_touch_down(state: Option<TouchDownState>) {
516    with_dnd_state(|storage| storage.touch_down = state);
517}
518
519fn update_touch_down(f: impl FnOnce(&mut TouchDownState)) {
520    with_dnd_state(|state| {
521        if let Some(touch) = state.touch_down.as_mut() {
522            f(touch);
523        }
524    });
525}
526
527fn with_session_mut<R>(f: impl FnOnce(&mut Option<DragSession>) -> R) -> R {
528    with_dnd_state(|state| f(&mut state.session))
529}
530
531fn take_session() -> Option<DragSession> {
532    with_dnd_state(|state| state.session.take())
533}
534
535fn set_session(session: DragSession) {
536    with_dnd_state(|state| state.session = Some(session));
537}
538
539/// Set the current frame for DnD hit-testing. Called by platform after each render.
540pub fn set_dnd_frame(frame: Option<Frame>) {
541    set_shared_dnd_frame(frame.map(Rc::new));
542}
543
544pub fn set_shared_dnd_frame(frame: Option<Rc<Frame>>) {
545    with_dnd_state(|state| state.frame = frame);
546}
547
548/// Set the display scale for DnD slop calculation.
549pub fn set_dnd_scale(scale: f32) {
550    with_dnd_state(|state| state.scale = scale);
551}
552
553/// Check if a drag session is currently active.
554pub fn is_dragging() -> bool {
555    with_dnd_state(|state| state.session.is_some())
556}
557
558/// Current drag session snapshot (if any).
559pub fn current_drag_session() -> Option<DragSession> {
560    with_dnd_state(|state| state.session.clone())
561}
562
563fn touch_slop_px(scale: f32) -> f32 {
564    6.0 * scale
565}
566
567fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
568    frame.hit_regions.iter().position(|h| h.id == id)
569}
570
571fn is_dnd_target(hit: &HitRegion) -> bool {
572    hit.on_drop.is_some()
573        || hit.on_drag_enter.is_some()
574        || hit.on_drag_over.is_some()
575        || hit.on_drag_leave.is_some()
576}
577
578pub fn dnd_target_id_at(frame: &Frame, pos: Vec2) -> Option<u64> {
579    frame
580        .hit_regions
581        .iter()
582        .rev()
583        .filter(|h| h.rect.contains(pos))
584        .find(|h| is_dnd_target(h))
585        .map(|h| h.id)
586}
587
588fn dnd_update_over(frame: &Frame, session: &mut DragSession, modifiers: Modifiers, pos: Vec2) {
589    let new_over = dnd_target_id_at(frame, pos);
590
591    if new_over != session.over_id {
592        if let Some(prev) = session.over_id
593            && let Some(i) = hit_index_by_id(frame, prev)
594            && let Some(cb) = &frame.hit_regions[i].on_drag_leave
595        {
596            cb(DragOver {
597                source_id: session.source_id,
598                target_id: prev,
599                position: pos,
600                modifiers,
601                payload: session.payload.clone(),
602            });
603        }
604
605        if let Some(now) = new_over
606            && let Some(i) = hit_index_by_id(frame, now)
607            && let Some(cb) = &frame.hit_regions[i].on_drag_enter
608        {
609            cb(DragOver {
610                source_id: session.source_id,
611                target_id: now,
612                position: pos,
613                modifiers,
614                payload: session.payload.clone(),
615            });
616        }
617
618        session.over_id = new_over;
619    }
620
621    if let Some(over) = session.over_id
622        && let Some(i) = hit_index_by_id(frame, over)
623        && let Some(cb) = &frame.hit_regions[i].on_drag_over
624    {
625        cb(DragOver {
626            source_id: session.source_id,
627            target_id: over,
628            position: pos,
629            modifiers,
630            payload: session.payload.clone(),
631        });
632    }
633}
634
635/// Finish a drag-and-drop session.
636fn dnd_finish(
637    frame: &Frame,
638    session: DragSession,
639    modifiers: Modifiers,
640    pos: Vec2,
641    accept_if_possible: bool,
642) -> bool {
643    let mut accepted = false;
644    if accept_if_possible {
645        let drop_target = dnd_target_id_at(frame, pos);
646        if let Some(tid) = drop_target
647            && let Some(i) = hit_index_by_id(frame, tid)
648            && let Some(cb) = &frame.hit_regions[i].on_drop
649        {
650            accepted = cb(DropEvent {
651                source_id: session.source_id,
652                target_id: tid,
653                position: pos,
654                modifiers,
655                payload: session.payload.clone(),
656            });
657        }
658    }
659
660    if let Some(i) = hit_index_by_id(frame, session.source_id)
661        && let Some(cb) = &frame.hit_regions[i].on_drag_end
662    {
663        cb(DragEnd { accepted });
664    }
665
666    accepted
667}
668
669fn initiate_drag(
670    frame: &Frame,
671    capture_id: u64,
672    start_pos: Vec2,
673    current_pos: Vec2,
674    modifiers: Modifiers,
675) -> bool {
676    let Some(i) = hit_index_by_id(frame, capture_id) else {
677        return false;
678    };
679    let Some(cb) = &frame.hit_regions[i].on_drag_start else {
680        return false;
681    };
682
683    // Clear any stale pending preview from a previous aborted start.
684    let _ = take_pending_preview();
685
686    let payload = cb(DragStart {
687        source_id: capture_id,
688        position: current_pos,
689        modifiers,
690    });
691    let Some(payload) = payload else {
692        let _ = take_pending_preview();
693        return false;
694    };
695
696    // Prefer provide_drag_preview(...) from on_drag_start; else modifier decoration.
697    let preview = take_pending_preview().or_else(|| frame.hit_regions[i].drag_preview.clone());
698
699    let source_rect = frame.hit_regions[i].rect;
700    let grab_offset = Vec2 {
701        x: start_pos.x - source_rect.x,
702        y: start_pos.y - source_rect.y,
703    };
704
705    set_session(DragSession {
706        source_id: capture_id,
707        payload,
708        start_px: (start_pos.x, start_pos.y),
709        over_id: None,
710        source_rect,
711        grab_offset,
712        preview,
713    });
714    true
715}
716
717/// Handle a DragAction from the platform. Returns true if the action was consumed.
718pub fn handle_drag_action(action: &DragAction) -> bool {
719    let scale = dnd_scale();
720    let slop = touch_slop_px(scale);
721
722    match *action {
723        DragAction::Press {
724            position,
725            capture_id,
726            kind,
727            ..
728        } => {
729            match kind {
730                PointerKind::Mouse => {
731                    set_mouse_down(Some(MouseDownState {
732                        position,
733                        capture_id,
734                    }));
735                }
736                _ => {
737                    set_touch_down(Some(TouchDownState {
738                        time: web_time::Instant::now(),
739                        position,
740                        capture_id,
741                        long_press_pending: true,
742                    }));
743                }
744            }
745            false
746        }
747
748        DragAction::Move {
749            position,
750            modifiers,
751        } => {
752            // If already dragging, update
753            if is_dragging() {
754                if let Some(frame) = dnd_frame() {
755                    with_session_mut(|session| {
756                        if let Some(session) = session.as_mut() {
757                            dnd_update_over(&frame, session, modifiers, position);
758                        }
759                    });
760                }
761                return true;
762            }
763
764            // Mouse: try drag initiation (drag past slop)
765            if let Some(down) = mouse_down() {
766                let dx = position.x - down.position.x;
767                let dy = position.y - down.position.y;
768                let dist = (dx * dx + dy * dy).sqrt();
769                if dist >= slop {
770                    if let Some(frame) = dnd_frame()
771                        && initiate_drag(
772                            &frame,
773                            down.capture_id,
774                            down.position,
775                            position,
776                            modifiers,
777                        )
778                    {
779                        with_session_mut(|session| {
780                            if let Some(session) = session.as_mut() {
781                                dnd_update_over(&frame, session, modifiers, position);
782                            }
783                        });
784                        set_mouse_down(None);
785                        return true;
786                    }
787                    set_mouse_down(None);
788                }
789                return true; // consumed: mouse is pressed, don't fall through to scroll
790            }
791
792            // Touch: try long-press initiation
793            if let Some(touch) = touch_down() {
794                if touch.long_press_pending {
795                    let elapsed_ms = (Instant::now() - touch.time).as_millis();
796                    let dx = position.x - touch.position.x;
797                    let dy = position.y - touch.position.y;
798                    let dist = (dx * dx + dy * dy).sqrt();
799
800                    if elapsed_ms >= LONG_PRESS_MS
801                        && dist <= slop
802                        && let Some(frame) = dnd_frame()
803                    {
804                        if initiate_drag(
805                            &frame,
806                            touch.capture_id,
807                            touch.position,
808                            position,
809                            modifiers,
810                        ) {
811                            with_session_mut(|session| {
812                                if let Some(session) = session.as_mut() {
813                                    dnd_update_over(&frame, session, modifiers, position);
814                                }
815                            });
816                            set_touch_down(None);
817                            return true;
818                        }
819                        update_touch_down(|touch| touch.long_press_pending = false);
820                    }
821                    if dist > slop {
822                        update_touch_down(|touch| touch.long_press_pending = false);
823                    }
824                }
825                // Only consume if still waiting for long-press (within slop, timer not yet expired).
826                // If long-press was cancelled (moved past slop), let scroll handle the event.
827                let still_pending = touch_down().is_some_and(|touch| touch.long_press_pending);
828                if still_pending {
829                    return true;
830                }
831            }
832
833            false
834        }
835
836        DragAction::Release {
837            position,
838            modifiers,
839        } => {
840            let mut consumed = false;
841
842            if let Some(session) = take_session() {
843                if let Some(frame) = dnd_frame() {
844                    dnd_finish(&frame, session, modifiers, position, true);
845                }
846                consumed = true;
847            }
848
849            set_mouse_down(None);
850            set_touch_down(None);
851
852            consumed
853        }
854
855        DragAction::Cancel => {
856            let mut consumed = false;
857            if let Some(session) = take_session() {
858                if let Some(frame) = dnd_frame() {
859                    dnd_finish(
860                        &frame,
861                        session,
862                        Modifiers::default(),
863                        Vec2::default(),
864                        false,
865                    );
866                }
867                consumed = true;
868            }
869            set_mouse_down(None);
870            set_touch_down(None);
871            consumed
872        }
873    }
874}
875
876/// Draw drag overlay indicator on the scene.
877/// `external_file_drag` enables orange styling for OS/browser file-drop overlays.
878pub fn overlay_drag_indicator(
879    scene: &mut Scene,
880    mouse_pos_px: (f32, f32),
881    external_file_drag: bool,
882) {
883    if !is_dragging() && !external_file_drag {
884        return;
885    }
886
887    let pos = Vec2 {
888        x: mouse_pos_px.0,
889        y: mouse_pos_px.1,
890    };
891
892    let frame = dnd_frame();
893    let Some(ref f) = frame else {
894        return;
895    };
896
897    let accent = if external_file_drag {
898        Color::from_hex("#FFAA00")
899    } else {
900        Color::from_hex("#44AAFF")
901    };
902
903    // Highlight best drop target under cursor
904    if let Some(tid) = dnd_target_id_at(f, pos)
905        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid)
906    {
907        use crate::units::UnitExt as _;
908        let r = 8.0.dp().to_px();
909        scene.nodes.push(SceneNode::Border {
910            rect: hit.rect,
911            brush: Brush::Solid(accent),
912            width: 2.0.dp().to_px(),
913            radius: [r; 4],
914        });
915    }
916
917    if external_file_drag {
918        draw_label_chip(scene, pos, "Drop files", accent, true);
919        return;
920    }
921
922    let Some(session) = current_drag_session() else {
923        return;
924    };
925    let ctx = DragPreviewCtx {
926        pointer: pos,
927        start_pointer: Vec2 {
928            x: session.start_px.0,
929            y: session.start_px.1,
930        },
931        source_rect: session.source_rect,
932        grab_offset: session.grab_offset,
933        payload: session.payload.clone(),
934    };
935
936    if let Some(preview) = session.preview {
937        preview(scene, &ctx);
938    } else {
939        draw_default_source_ghost(scene, &ctx, accent);
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946    use crate::input::PointerKind;
947    use crate::runtime::HitRegion;
948
949    #[test]
950    fn contexts_isolate_drag_sessions() {
951        let frame = Frame {
952            scene: Scene::default(),
953            hit_regions: vec![HitRegion {
954                id: 1,
955                rect: Rect {
956                    x: 0.0,
957                    y: 0.0,
958                    w: 100.0,
959                    h: 100.0,
960                },
961                on_drag_start: Some(Rc::new(|_| Some(drag_payload(7u32)))),
962                ..Default::default()
963            }],
964            semantics_nodes: Vec::new(),
965            focus_chain: Vec::new(),
966        };
967        let a = DndContext::default();
968        let b = DndContext::default();
969        {
970            let _guard = a.enter();
971            set_dnd_frame(Some(frame.clone()));
972            handle_drag_action(&DragAction::Press {
973                position: Vec2 { x: 10.0, y: 10.0 },
974                capture_id: 1,
975                kind: PointerKind::Mouse,
976                modifiers: Modifiers::default(),
977            });
978            handle_drag_action(&DragAction::Move {
979                position: Vec2 { x: 40.0, y: 40.0 },
980                modifiers: Modifiers::default(),
981            });
982            assert!(is_dragging());
983        }
984        {
985            let _guard = b.enter();
986            assert!(!is_dragging());
987            set_dnd_frame(Some(frame.clone()));
988        }
989        {
990            let _guard = a.enter();
991            assert!(is_dragging());
992            handle_drag_action(&DragAction::Cancel);
993            assert!(!is_dragging());
994        }
995    }
996}