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::{FontStyle, FontWeight, TextAlign, TextDecoration};
8use crate::view::{Scene, SceneNode};
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// Optional one-shot preview set from inside `on_drag_start` (overrides modifier).
250thread_local! {
251    static PENDING_PREVIEW: RefCell<Option<DragPreview>> = const { RefCell::new(None) };
252}
253
254/// Call from `on_drag_start` to supply a session-specific preview.
255///
256/// ```ignore
257/// .on_drag_start(move |_s| {
258///     provide_drag_preview(drag_preview_label(title.clone(), Color::from_hex("#44AAFF")));
259///     Some(drag_payload(MyItem { .. }))
260/// })
261/// ```
262pub fn provide_drag_preview(preview: DragPreview) {
263    PENDING_PREVIEW.with(|p| *p.borrow_mut() = Some(preview));
264}
265
266fn take_pending_preview() -> Option<DragPreview> {
267    PENDING_PREVIEW.with(|p| p.borrow_mut().take())
268}
269
270/// Simple floating label chip (good default for tabs / list rows).
271pub fn drag_preview_label(label: impl Into<String>, accent: Color) -> DragPreview {
272    let label = label.into();
273    Rc::new(move |scene: &mut Scene, ctx: &DragPreviewCtx| {
274        draw_label_chip(scene, ctx.pointer, &label, accent, false);
275    })
276}
277
278/// Label chip with a slightly larger "elevated" look (shadow-ish fill).
279pub fn drag_preview_chip(label: impl Into<String>, accent: Color) -> DragPreview {
280    let label = label.into();
281    Rc::new(move |scene: &mut Scene, ctx: &DragPreviewCtx| {
282        draw_label_chip(scene, ctx.pointer, &label, accent, true);
283    })
284}
285
286fn draw_label_chip(scene: &mut Scene, pointer: Vec2, label: &str, accent: Color, elevated: bool) {
287    use crate::units::{Px, UnitExt as _};
288    // Paint-space: Dp/Sp converted at the boundary (Compose `Density.toPx`).
289    // `Rect` carries px magnitudes; scalar node fields take `Px`.
290    let pad_x = 10.0.dp().to_px();
291    let pad_y = 6.0.dp().to_px();
292    let font_px = 13.0.sp().to_px();
293    // Approximate width: ~0.55em per char (good enough without measuring).
294    let text_w = Px((label.chars().count() as f32 * font_px.0 * 0.55).max(24.0.dp().to_px().0));
295    let w = text_w.0 + pad_x.0 * 2.0;
296    let h = font_px.0 + pad_y.0 * 2.0;
297    let r = 8.0.dp().to_px();
298
299    let origin = Vec2 {
300        x: pointer.x + 14.0.dp().to_px().0,
301        y: pointer.y + 14.0.dp().to_px().0,
302    };
303    let rect = Rect {
304        x: origin.x,
305        y: origin.y,
306        w,
307        h,
308    };
309
310    if elevated {
311        // Soft "shadow"
312        scene.nodes.push(SceneNode::Rect {
313            rect: Rect {
314                x: rect.x + 2.0.dp().to_px().0,
315                y: rect.y + 3.0.dp().to_px().0,
316                w: rect.w,
317                h: rect.h,
318            },
319            brush: Brush::Solid(Color::from_rgba(0, 0, 0, 50)),
320            radius: [r; 4],
321        });
322    }
323
324    let bg = accent.with_alpha(0xDD);
325    scene.nodes.push(SceneNode::Rect {
326        rect,
327        brush: Brush::Solid(bg),
328        radius: [r; 4],
329    });
330    scene.nodes.push(SceneNode::Border {
331        rect,
332        color: accent.with_alpha(0xFF),
333        width: 1.0.dp().to_px(),
334        radius: [r; 4],
335    });
336    scene.nodes.push(SceneNode::Text {
337        rect: Rect {
338            x: rect.x + pad_x.0,
339            y: rect.y + pad_y.0,
340            w: text_w.0,
341            h: font_px.0,
342        },
343        text: Arc::<str>::from(label),
344        color: Color::WHITE,
345        size: font_px,
346        font_family: None,
347        text_align: TextAlign::Unspecified,
348        font_weight: FontWeight::MEDIUM,
349        font_style: FontStyle::Normal,
350        text_decoration: TextDecoration::default(),
351        letter_spacing: Px::ZERO,
352        line_height: Px::ZERO,
353        extra_style: Default::default(),
354        url: None,
355        font_variation_settings: None,
356    });
357}
358
359/// Default ghost: translucent clone of the source bounds, locked to grab offset.
360fn draw_default_source_ghost(scene: &mut Scene, ctx: &DragPreviewCtx, accent: Color) {
361    use crate::units::UnitExt as _;
362    let w = ctx.source_rect.w.max(24.0.dp().to_px().0);
363    let h = ctx.source_rect.h.max(16.0.dp().to_px().0);
364    let rect = Rect {
365        x: ctx.pointer.x - ctx.grab_offset.x,
366        y: ctx.pointer.y - ctx.grab_offset.y,
367        w,
368        h,
369    };
370    let r = 6.0.dp().to_px();
371    scene.nodes.push(SceneNode::Rect {
372        rect,
373        brush: Brush::Solid(accent.with_alpha(0x55)),
374        radius: [r; 4],
375    });
376    scene.nodes.push(SceneNode::Border {
377        rect,
378        color: accent.with_alpha(0xCC),
379        width: 1.5.dp().to_px(),
380        radius: [r; 4],
381    });
382}
383
384/// A single dropped file descriptor.
385/// - On desktop: `path` is `Some(PathBuf)`.
386/// - On web: `path` is usually `None` (browser doesn't expose local paths).
387#[derive(Clone, Debug)]
388pub struct DroppedFile {
389    pub name: String,
390    pub path: Option<PathBuf>,
391}
392
393/// Payload type for file drag/drop coming from the OS/browser.
394#[derive(Clone, Debug)]
395pub struct DroppedFiles {
396    pub files: Vec<DroppedFile>,
397}
398
399/// Tracks an active drag session (internal widget-to-widget DnD).
400#[derive(Clone)]
401pub struct DragSession {
402    pub source_id: u64,
403    pub payload: DragPayload,
404    pub start_px: (f32, f32),
405    pub over_id: Option<u64>,
406    /// Source hit rect at drag start (px).
407    pub source_rect: Rect,
408    /// Pointer - source origin at drag start.
409    pub grab_offset: Vec2,
410    /// Optional custom decoration (Compose `drawDragDecoration`).
411    pub preview: Option<DragPreview>,
412}
413
414// Manual Debug (Rc<dyn Fn> is not Debug)
415impl std::fmt::Debug for DragSession {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        f.debug_struct("DragSession")
418            .field("source_id", &self.source_id)
419            .field("start_px", &self.start_px)
420            .field("over_id", &self.over_id)
421            .field("source_rect", &self.source_rect)
422            .field("grab_offset", &self.grab_offset)
423            .field("preview", &self.preview.as_ref().map(|_| "…"))
424            .finish_non_exhaustive()
425    }
426}
427
428#[derive(Clone)]
429struct MouseDownState {
430    position: Vec2,
431    capture_id: u64,
432}
433
434#[derive(Clone)]
435struct TouchDownState {
436    time: Instant,
437    position: Vec2,
438    capture_id: u64,
439    long_press_pending: bool,
440}
441
442const LONG_PRESS_MS: u128 = 400;
443
444thread_local! {
445    static DND_FRAME: RefCell<Option<Frame>> = const { RefCell::new(None) };
446    static DND_SCALE: RefCell<f32> = const { RefCell::new(1.0) };
447    static DND_SESSION: RefCell<Option<DragSession>> = const { RefCell::new(None) };
448    static DND_MOUSE_DOWN: RefCell<Option<MouseDownState>> = const { RefCell::new(None) };
449    static DND_TOUCH_DOWN: RefCell<Option<TouchDownState>> = const { RefCell::new(None) };
450}
451
452/// Set the current frame for DnD hit-testing. Called by platform after each render.
453pub fn set_dnd_frame(frame: Option<Frame>) {
454    DND_FRAME.with(|f| *f.borrow_mut() = frame);
455}
456
457/// Set the display scale for DnD slop calculation.
458pub fn set_dnd_scale(scale: f32) {
459    DND_SCALE.with(|s| *s.borrow_mut() = scale);
460}
461
462/// Check if a drag session is currently active.
463pub fn is_dragging() -> bool {
464    DND_SESSION.with(|s| s.borrow().is_some())
465}
466
467/// Current drag session snapshot (if any).
468pub fn current_drag_session() -> Option<DragSession> {
469    DND_SESSION.with(|s| s.borrow().clone())
470}
471
472fn touch_slop_px(scale: f32) -> f32 {
473    6.0 * scale
474}
475
476fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
477    frame.hit_regions.iter().position(|h| h.id == id)
478}
479
480fn is_dnd_target(hit: &HitRegion) -> bool {
481    hit.on_drop.is_some()
482        || hit.on_drag_enter.is_some()
483        || hit.on_drag_over.is_some()
484        || hit.on_drag_leave.is_some()
485}
486
487pub fn dnd_target_id_at(frame: &Frame, pos: Vec2) -> Option<u64> {
488    frame
489        .hit_regions
490        .iter()
491        .rev()
492        .filter(|h| h.rect.contains(pos))
493        .find(|h| is_dnd_target(h))
494        .map(|h| h.id)
495}
496
497fn dnd_update_over(frame: &Frame, session: &mut DragSession, modifiers: Modifiers, pos: Vec2) {
498    let new_over = dnd_target_id_at(frame, pos);
499
500    if new_over != session.over_id {
501        if let Some(prev) = session.over_id
502            && let Some(i) = hit_index_by_id(frame, prev)
503            && let Some(cb) = &frame.hit_regions[i].on_drag_leave
504        {
505            cb(DragOver {
506                source_id: session.source_id,
507                target_id: prev,
508                position: pos,
509                modifiers,
510                payload: session.payload.clone(),
511            });
512        }
513
514        if let Some(now) = new_over
515            && let Some(i) = hit_index_by_id(frame, now)
516            && let Some(cb) = &frame.hit_regions[i].on_drag_enter
517        {
518            cb(DragOver {
519                source_id: session.source_id,
520                target_id: now,
521                position: pos,
522                modifiers,
523                payload: session.payload.clone(),
524            });
525        }
526
527        session.over_id = new_over;
528    }
529
530    if let Some(over) = session.over_id
531        && let Some(i) = hit_index_by_id(frame, over)
532        && let Some(cb) = &frame.hit_regions[i].on_drag_over
533    {
534        cb(DragOver {
535            source_id: session.source_id,
536            target_id: over,
537            position: pos,
538            modifiers,
539            payload: session.payload.clone(),
540        });
541    }
542}
543
544/// Finish a drag-and-drop session.
545fn dnd_finish(
546    frame: &Frame,
547    session: DragSession,
548    modifiers: Modifiers,
549    pos: Vec2,
550    accept_if_possible: bool,
551) -> bool {
552    let mut accepted = false;
553    if accept_if_possible {
554        let drop_target = dnd_target_id_at(frame, pos);
555        if let Some(tid) = drop_target
556            && let Some(i) = hit_index_by_id(frame, tid)
557            && let Some(cb) = &frame.hit_regions[i].on_drop
558        {
559            accepted = cb(DropEvent {
560                source_id: session.source_id,
561                target_id: tid,
562                position: pos,
563                modifiers,
564                payload: session.payload.clone(),
565            });
566        }
567    }
568
569    if let Some(i) = hit_index_by_id(frame, session.source_id)
570        && let Some(cb) = &frame.hit_regions[i].on_drag_end
571    {
572        cb(DragEnd { accepted });
573    }
574
575    accepted
576}
577
578fn initiate_drag(
579    frame: &Frame,
580    capture_id: u64,
581    start_pos: Vec2,
582    current_pos: Vec2,
583    modifiers: Modifiers,
584) -> bool {
585    let Some(i) = hit_index_by_id(frame, capture_id) else {
586        return false;
587    };
588    let Some(cb) = &frame.hit_regions[i].on_drag_start else {
589        return false;
590    };
591
592    // Clear any stale pending preview from a previous aborted start.
593    let _ = take_pending_preview();
594
595    let payload = cb(DragStart {
596        source_id: capture_id,
597        position: current_pos,
598        modifiers,
599    });
600    let Some(payload) = payload else {
601        let _ = take_pending_preview();
602        return false;
603    };
604
605    // Prefer provide_drag_preview(...) from on_drag_start; else modifier decoration.
606    let preview = take_pending_preview().or_else(|| frame.hit_regions[i].drag_preview.clone());
607
608    let source_rect = frame.hit_regions[i].rect;
609    let grab_offset = Vec2 {
610        x: start_pos.x - source_rect.x,
611        y: start_pos.y - source_rect.y,
612    };
613
614    DND_SESSION.with(|s| {
615        *s.borrow_mut() = Some(DragSession {
616            source_id: capture_id,
617            payload,
618            start_px: (start_pos.x, start_pos.y),
619            over_id: None,
620            source_rect,
621            grab_offset,
622            preview,
623        });
624    });
625    true
626}
627
628/// Handle a DragAction from the platform. Returns true if the action was consumed.
629pub fn handle_drag_action(action: &DragAction) -> bool {
630    let scale = DND_SCALE.with(|s| *s.borrow());
631    let slop = touch_slop_px(scale);
632
633    match *action {
634        DragAction::Press {
635            position,
636            capture_id,
637            kind,
638            ..
639        } => {
640            match kind {
641                PointerKind::Mouse => {
642                    DND_MOUSE_DOWN.with(|m| {
643                        *m.borrow_mut() = Some(MouseDownState {
644                            position,
645                            capture_id,
646                        });
647                    });
648                }
649                _ => {
650                    // Touch (or pen/unknown): start long-press timer
651                    DND_TOUCH_DOWN.with(|t| {
652                        *t.borrow_mut() = Some(TouchDownState {
653                            time: web_time::Instant::now(),
654                            position,
655                            capture_id,
656                            long_press_pending: true,
657                        });
658                    });
659                }
660            }
661            false
662        }
663
664        DragAction::Move {
665            position,
666            modifiers,
667        } => {
668            // If already dragging, update
669            if DND_SESSION.with(|s| s.borrow().is_some()) {
670                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
671                    DND_SESSION.with(|s| {
672                        if let Some(ref mut session) = *s.borrow_mut() {
673                            dnd_update_over(&frame, session, modifiers, position);
674                        }
675                    });
676                }
677                return true;
678            }
679
680            // Mouse: try drag initiation (drag past slop)
681            if let Some(down) = DND_MOUSE_DOWN.with(|m| m.borrow().clone()) {
682                let dx = position.x - down.position.x;
683                let dy = position.y - down.position.y;
684                let dist = (dx * dx + dy * dy).sqrt();
685                if dist >= slop {
686                    if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone())
687                        && initiate_drag(
688                            &frame,
689                            down.capture_id,
690                            down.position,
691                            position,
692                            modifiers,
693                        )
694                    {
695                        DND_SESSION.with(|s| {
696                            if let Some(ref mut session) = *s.borrow_mut() {
697                                dnd_update_over(&frame, session, modifiers, position);
698                            }
699                        });
700                        DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
701                        return true;
702                    }
703                    // Widget doesn't support drag - try mouse down again next time
704                    // (actually, clear it so we don't retry on every move)
705                    DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
706                }
707                return true; // consumed: mouse is pressed, don't fall through to scroll
708            }
709
710            // Touch: try long-press initiation
711            if let Some(touch) = DND_TOUCH_DOWN.with(|t| t.borrow().clone()) {
712                if touch.long_press_pending {
713                    let elapsed_ms = (Instant::now() - touch.time).as_millis();
714                    let dx = position.x - touch.position.x;
715                    let dy = position.y - touch.position.y;
716                    let dist = (dx * dx + dy * dy).sqrt();
717
718                    if elapsed_ms >= LONG_PRESS_MS
719                        && dist <= slop
720                        && let Some(frame) = DND_FRAME.with(|f| f.borrow().clone())
721                    {
722                        if initiate_drag(
723                            &frame,
724                            touch.capture_id,
725                            touch.position,
726                            position,
727                            modifiers,
728                        ) {
729                            DND_SESSION.with(|s| {
730                                if let Some(ref mut session) = *s.borrow_mut() {
731                                    dnd_update_over(&frame, session, modifiers, position);
732                                }
733                            });
734                            DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
735                            return true;
736                        }
737                        // Widget doesn't support drag - cancel long press
738                        DND_TOUCH_DOWN.with(|t| {
739                            if let Some(ref mut td) = *t.borrow_mut() {
740                                td.long_press_pending = false;
741                            }
742                        });
743                    }
744                    if dist > slop {
745                        DND_TOUCH_DOWN.with(|t| {
746                            if let Some(ref mut td) = *t.borrow_mut() {
747                                td.long_press_pending = false;
748                            }
749                        });
750                    }
751                }
752                // Only consume if still waiting for long-press (within slop, timer not yet expired).
753                // If long-press was cancelled (moved past slop), let scroll handle the event.
754                let still_pending = DND_TOUCH_DOWN.with(|t| {
755                    t.borrow()
756                        .as_ref()
757                        .map(|td| td.long_press_pending)
758                        .unwrap_or(false)
759                });
760                if still_pending {
761                    return true;
762                }
763            }
764
765            false
766        }
767
768        DragAction::Release {
769            position,
770            modifiers,
771        } => {
772            let mut consumed = false;
773
774            if let Some(session) = DND_SESSION.with(|s| s.borrow_mut().take()) {
775                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
776                    dnd_finish(&frame, session, modifiers, position, true);
777                }
778                consumed = true;
779            }
780
781            DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
782            DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
783
784            consumed
785        }
786
787        DragAction::Cancel => {
788            let mut consumed = false;
789            if let Some(session) = DND_SESSION.with(|s| s.borrow_mut().take()) {
790                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
791                    dnd_finish(
792                        &frame,
793                        session,
794                        Modifiers::default(),
795                        Vec2::default(),
796                        false,
797                    );
798                }
799                consumed = true;
800            }
801            DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
802            DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
803            consumed
804        }
805    }
806}
807
808/// Draw drag overlay indicator on the scene.
809/// `external_file_drag` enables orange styling for OS/browser file-drop overlays.
810pub fn overlay_drag_indicator(
811    scene: &mut Scene,
812    mouse_pos_px: (f32, f32),
813    external_file_drag: bool,
814) {
815    if !is_dragging() && !external_file_drag {
816        return;
817    }
818
819    let pos = Vec2 {
820        x: mouse_pos_px.0,
821        y: mouse_pos_px.1,
822    };
823
824    let frame = DND_FRAME.with(|f| f.borrow().clone());
825    let Some(ref f) = frame else {
826        return;
827    };
828
829    let accent = if external_file_drag {
830        Color::from_hex("#FFAA00")
831    } else {
832        Color::from_hex("#44AAFF")
833    };
834
835    // Highlight best drop target under cursor
836    if let Some(tid) = dnd_target_id_at(f, pos)
837        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid)
838    {
839        use crate::units::UnitExt as _;
840        let r = 8.0.dp().to_px();
841        scene.nodes.push(SceneNode::Border {
842            rect: hit.rect,
843            color: accent,
844            width: 2.0.dp().to_px(),
845            radius: [r; 4],
846        });
847    }
848
849    if external_file_drag {
850        draw_label_chip(scene, pos, "Drop files", accent, true);
851        return;
852    }
853
854    DND_SESSION.with(|s| {
855        let session = s.borrow();
856        let Some(ref session) = *session else {
857            return;
858        };
859
860        let ctx = DragPreviewCtx {
861            pointer: pos,
862            start_pointer: Vec2 {
863                x: session.start_px.0,
864                y: session.start_px.1,
865            },
866            source_rect: session.source_rect,
867            grab_offset: session.grab_offset,
868            payload: session.payload.clone(),
869        };
870
871        if let Some(ref preview) = session.preview {
872            preview(scene, &ctx);
873        } else {
874            draw_default_source_ghost(scene, &ctx, accent);
875        }
876    });
877}