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