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