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/// 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        font_family: None,
342        text_align: TextAlign::Unspecified,
343        font_weight: FontWeight::MEDIUM,
344        font_style: FontStyle::Normal,
345        text_decoration: TextDecoration::default(),
346        letter_spacing: Px::ZERO,
347        line_height: Px::ZERO,
348        extra_style: Default::default(),
349        url: None,
350        font_variation_settings: None,
351    });
352}
353
354/// Default ghost: translucent clone of the source bounds, locked to grab offset.
355fn draw_default_source_ghost(scene: &mut Scene, ctx: &DragPreviewCtx, accent: Color) {
356    use crate::units::UnitExt as _;
357    let w = ctx.source_rect.w.max(24.0.dp().to_px().0);
358    let h = ctx.source_rect.h.max(16.0.dp().to_px().0);
359    let rect = Rect {
360        x: ctx.pointer.x - ctx.grab_offset.x,
361        y: ctx.pointer.y - ctx.grab_offset.y,
362        w,
363        h,
364    };
365    let r = 6.0.dp().to_px();
366    scene.nodes.push(SceneNode::Rect {
367        rect,
368        brush: Brush::Solid(accent.with_alpha(0x55)),
369        radius: [r; 4],
370    });
371    scene.nodes.push(SceneNode::Border {
372        rect,
373        brush: Brush::Solid(accent.with_alpha(0xCC)),
374        width: 1.5.dp().to_px(),
375        radius: [r; 4],
376    });
377}
378
379/// A single dropped file descriptor.
380/// - On desktop: `path` is `Some(PathBuf)`.
381/// - On web: `path` is usually `None` (browser doesn't expose local paths).
382#[derive(Clone, Debug)]
383pub struct DroppedFile {
384    pub name: String,
385    pub path: Option<PathBuf>,
386}
387
388/// Payload type for file drag/drop coming from the OS/browser.
389#[derive(Clone, Debug)]
390pub struct DroppedFiles {
391    pub files: Vec<DroppedFile>,
392}
393
394/// Tracks an active drag session (internal widget-to-widget DnD).
395#[derive(Clone)]
396pub struct DragSession {
397    pub source_id: u64,
398    pub payload: DragPayload,
399    pub start_px: (f32, f32),
400    pub over_id: Option<u64>,
401    /// Source hit rect at drag start (px).
402    pub source_rect: Rect,
403    /// Pointer - source origin at drag start.
404    pub grab_offset: Vec2,
405    /// Optional custom decoration (Compose `drawDragDecoration`).
406    pub preview: Option<DragPreview>,
407}
408
409// Manual Debug (Rc<dyn Fn> is not Debug)
410impl std::fmt::Debug for DragSession {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.debug_struct("DragSession")
413            .field("source_id", &self.source_id)
414            .field("start_px", &self.start_px)
415            .field("over_id", &self.over_id)
416            .field("source_rect", &self.source_rect)
417            .field("grab_offset", &self.grab_offset)
418            .field("preview", &self.preview.as_ref().map(|_| "…"))
419            .finish_non_exhaustive()
420    }
421}
422
423#[derive(Clone)]
424struct MouseDownState {
425    position: Vec2,
426    capture_id: u64,
427}
428
429#[derive(Clone)]
430struct TouchDownState {
431    time: Instant,
432    position: Vec2,
433    capture_id: u64,
434    long_press_pending: bool,
435}
436
437const LONG_PRESS_MS: u128 = 400;
438
439#[derive(Default)]
440struct DndStorage {
441    frame: Option<Rc<Frame>>,
442    scale: f32,
443    session: Option<DragSession>,
444    mouse_down: Option<MouseDownState>,
445    touch_down: Option<TouchDownState>,
446    pending_preview: Option<DragPreview>,
447}
448
449#[derive(Clone)]
450pub struct DndContext {
451    storage: Rc<RefCell<DndStorage>>,
452}
453
454impl Default for DndContext {
455    fn default() -> Self {
456        Self {
457            storage: Rc::new(RefCell::new(DndStorage {
458                scale: 1.0,
459                ..DndStorage::default()
460            })),
461        }
462    }
463}
464
465pub struct DndContextGuard {
466    previous: Option<DndContext>,
467}
468
469impl DndContext {
470    pub fn enter(&self) -> DndContextGuard {
471        let previous = ACTIVE_DND_CONTEXT.with(|slot| slot.borrow_mut().replace(self.clone()));
472        DndContextGuard { previous }
473    }
474}
475
476impl Drop for DndContextGuard {
477    fn drop(&mut self) {
478        ACTIVE_DND_CONTEXT.with(|slot| {
479            *slot.borrow_mut() = self.previous.take();
480        });
481    }
482}
483
484thread_local! {
485    static GLOBAL_DND: RefCell<DndStorage> = RefCell::new(DndStorage {
486        scale: 1.0,
487        ..DndStorage::default()
488    });
489    static ACTIVE_DND_CONTEXT: RefCell<Option<DndContext>> = const { RefCell::new(None) };
490}
491
492fn with_dnd_state<R>(f: impl FnOnce(&mut DndStorage) -> R) -> R {
493    if let Some(context) = ACTIVE_DND_CONTEXT.with(|slot| slot.borrow().clone()) {
494        let mut storage = context.storage.borrow_mut();
495        f(&mut storage)
496    } else {
497        GLOBAL_DND.with(|storage| f(&mut storage.borrow_mut()))
498    }
499}
500
501fn dnd_frame() -> Option<Rc<Frame>> {
502    with_dnd_state(|state| state.frame.clone())
503}
504
505fn dnd_scale() -> f32 {
506    with_dnd_state(|state| state.scale)
507}
508
509fn mouse_down() -> Option<MouseDownState> {
510    with_dnd_state(|state| state.mouse_down.clone())
511}
512
513fn set_mouse_down(state: Option<MouseDownState>) {
514    with_dnd_state(|storage| storage.mouse_down = state);
515}
516
517fn touch_down() -> Option<TouchDownState> {
518    with_dnd_state(|state| state.touch_down.clone())
519}
520
521fn set_touch_down(state: Option<TouchDownState>) {
522    with_dnd_state(|storage| storage.touch_down = state);
523}
524
525fn update_touch_down(f: impl FnOnce(&mut TouchDownState)) {
526    with_dnd_state(|state| {
527        if let Some(touch) = state.touch_down.as_mut() {
528            f(touch);
529        }
530    });
531}
532
533fn with_session_mut<R>(f: impl FnOnce(&mut Option<DragSession>) -> R) -> R {
534    with_dnd_state(|state| f(&mut state.session))
535}
536
537fn take_session() -> Option<DragSession> {
538    with_dnd_state(|state| state.session.take())
539}
540
541fn set_session(session: DragSession) {
542    with_dnd_state(|state| state.session = Some(session));
543}
544
545/// Set the current frame for DnD hit-testing. Called by platform after each render.
546pub fn set_dnd_frame(frame: Option<Frame>) {
547    set_shared_dnd_frame(frame.map(Rc::new));
548}
549
550pub fn set_shared_dnd_frame(frame: Option<Rc<Frame>>) {
551    with_dnd_state(|state| state.frame = frame);
552}
553
554/// Set the display scale for DnD slop calculation.
555pub fn set_dnd_scale(scale: f32) {
556    with_dnd_state(|state| state.scale = scale);
557}
558
559/// Check if a drag session is currently active.
560pub fn is_dragging() -> bool {
561    with_dnd_state(|state| state.session.is_some())
562}
563
564/// Current drag session snapshot (if any).
565pub fn current_drag_session() -> Option<DragSession> {
566    with_dnd_state(|state| state.session.clone())
567}
568
569fn touch_slop_px(scale: f32) -> f32 {
570    6.0 * scale
571}
572
573fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
574    frame.hit_regions.iter().position(|h| h.id == id)
575}
576
577fn is_dnd_target(hit: &HitRegion) -> bool {
578    hit.on_drop.is_some()
579        || hit.on_drag_enter.is_some()
580        || hit.on_drag_over.is_some()
581        || hit.on_drag_leave.is_some()
582}
583
584pub fn dnd_target_id_at(frame: &Frame, pos: Vec2) -> Option<u64> {
585    frame
586        .hit_regions
587        .iter()
588        .rev()
589        .filter(|h| h.rect.contains(pos))
590        .find(|h| is_dnd_target(h))
591        .map(|h| h.id)
592}
593
594fn dnd_update_over(frame: &Frame, session: &mut DragSession, modifiers: Modifiers, pos: Vec2) {
595    let new_over = dnd_target_id_at(frame, pos);
596
597    if new_over != session.over_id {
598        if let Some(prev) = session.over_id
599            && let Some(i) = hit_index_by_id(frame, prev)
600            && let Some(cb) = &frame.hit_regions[i].on_drag_leave
601        {
602            cb(DragOver {
603                source_id: session.source_id,
604                target_id: prev,
605                position: pos,
606                modifiers,
607                payload: session.payload.clone(),
608            });
609        }
610
611        if let Some(now) = new_over
612            && let Some(i) = hit_index_by_id(frame, now)
613            && let Some(cb) = &frame.hit_regions[i].on_drag_enter
614        {
615            cb(DragOver {
616                source_id: session.source_id,
617                target_id: now,
618                position: pos,
619                modifiers,
620                payload: session.payload.clone(),
621            });
622        }
623
624        session.over_id = new_over;
625    }
626
627    if let Some(over) = session.over_id
628        && let Some(i) = hit_index_by_id(frame, over)
629        && let Some(cb) = &frame.hit_regions[i].on_drag_over
630    {
631        cb(DragOver {
632            source_id: session.source_id,
633            target_id: over,
634            position: pos,
635            modifiers,
636            payload: session.payload.clone(),
637        });
638    }
639}
640
641/// Finish a drag-and-drop session.
642fn dnd_finish(
643    frame: &Frame,
644    session: DragSession,
645    modifiers: Modifiers,
646    pos: Vec2,
647    accept_if_possible: bool,
648) -> bool {
649    let mut accepted = false;
650    if accept_if_possible {
651        let drop_target = dnd_target_id_at(frame, pos);
652        if let Some(tid) = drop_target
653            && let Some(i) = hit_index_by_id(frame, tid)
654            && let Some(cb) = &frame.hit_regions[i].on_drop
655        {
656            accepted = cb(DropEvent {
657                source_id: session.source_id,
658                target_id: tid,
659                position: pos,
660                modifiers,
661                payload: session.payload.clone(),
662            });
663        }
664    }
665
666    if let Some(i) = hit_index_by_id(frame, session.source_id)
667        && let Some(cb) = &frame.hit_regions[i].on_drag_end
668    {
669        cb(DragEnd { accepted });
670    }
671
672    accepted
673}
674
675fn initiate_drag(
676    frame: &Frame,
677    capture_id: u64,
678    start_pos: Vec2,
679    current_pos: Vec2,
680    modifiers: Modifiers,
681) -> bool {
682    let Some(i) = hit_index_by_id(frame, capture_id) else {
683        return false;
684    };
685    let Some(cb) = &frame.hit_regions[i].on_drag_start else {
686        return false;
687    };
688
689    // Clear any stale pending preview from a previous aborted start.
690    let _ = take_pending_preview();
691
692    let payload = cb(DragStart {
693        source_id: capture_id,
694        position: current_pos,
695        modifiers,
696    });
697    let Some(payload) = payload else {
698        let _ = take_pending_preview();
699        return false;
700    };
701
702    // Prefer provide_drag_preview(...) from on_drag_start; else modifier decoration.
703    let preview = take_pending_preview().or_else(|| frame.hit_regions[i].drag_preview.clone());
704
705    let source_rect = frame.hit_regions[i].rect;
706    let grab_offset = Vec2 {
707        x: start_pos.x - source_rect.x,
708        y: start_pos.y - source_rect.y,
709    };
710
711    set_session(DragSession {
712        source_id: capture_id,
713        payload,
714        start_px: (start_pos.x, start_pos.y),
715        over_id: None,
716        source_rect,
717        grab_offset,
718        preview,
719    });
720    true
721}
722
723/// Handle a DragAction from the platform. Returns true if the action was consumed.
724pub fn handle_drag_action(action: &DragAction) -> bool {
725    let scale = dnd_scale();
726    let slop = touch_slop_px(scale);
727
728    match *action {
729        DragAction::Press {
730            position,
731            capture_id,
732            kind,
733            ..
734        } => {
735            match kind {
736                PointerKind::Mouse => {
737                    set_mouse_down(Some(MouseDownState {
738                        position,
739                        capture_id,
740                    }));
741                }
742                _ => {
743                    set_touch_down(Some(TouchDownState {
744                        time: web_time::Instant::now(),
745                        position,
746                        capture_id,
747                        long_press_pending: true,
748                    }));
749                }
750            }
751            false
752        }
753
754        DragAction::Move {
755            position,
756            modifiers,
757        } => {
758            // If already dragging, update
759            if is_dragging() {
760                if let Some(frame) = dnd_frame() {
761                    with_session_mut(|session| {
762                        if let Some(session) = session.as_mut() {
763                            dnd_update_over(&frame, session, modifiers, position);
764                        }
765                    });
766                }
767                return true;
768            }
769
770            // Mouse: try drag initiation (drag past slop)
771            if let Some(down) = mouse_down() {
772                let dx = position.x - down.position.x;
773                let dy = position.y - down.position.y;
774                let dist = (dx * dx + dy * dy).sqrt();
775                if dist >= slop {
776                    if let Some(frame) = dnd_frame()
777                        && initiate_drag(
778                            &frame,
779                            down.capture_id,
780                            down.position,
781                            position,
782                            modifiers,
783                        )
784                    {
785                        with_session_mut(|session| {
786                            if let Some(session) = session.as_mut() {
787                                dnd_update_over(&frame, session, modifiers, position);
788                            }
789                        });
790                        set_mouse_down(None);
791                        return true;
792                    }
793                    set_mouse_down(None);
794                }
795                return true; // consumed: mouse is pressed, don't fall through to scroll
796            }
797
798            // Touch: try long-press initiation
799            if let Some(touch) = touch_down() {
800                if touch.long_press_pending {
801                    let elapsed_ms = (Instant::now() - touch.time).as_millis();
802                    let dx = position.x - touch.position.x;
803                    let dy = position.y - touch.position.y;
804                    let dist = (dx * dx + dy * dy).sqrt();
805
806                    if elapsed_ms >= LONG_PRESS_MS
807                        && dist <= slop
808                        && let Some(frame) = dnd_frame()
809                    {
810                        if initiate_drag(
811                            &frame,
812                            touch.capture_id,
813                            touch.position,
814                            position,
815                            modifiers,
816                        ) {
817                            with_session_mut(|session| {
818                                if let Some(session) = session.as_mut() {
819                                    dnd_update_over(&frame, session, modifiers, position);
820                                }
821                            });
822                            set_touch_down(None);
823                            return true;
824                        }
825                        update_touch_down(|touch| touch.long_press_pending = false);
826                    }
827                    if dist > slop {
828                        update_touch_down(|touch| touch.long_press_pending = false);
829                    }
830                }
831                // Only consume if still waiting for long-press (within slop, timer not yet expired).
832                // If long-press was cancelled (moved past slop), let scroll handle the event.
833                let still_pending = touch_down().is_some_and(|touch| touch.long_press_pending);
834                if still_pending {
835                    return true;
836                }
837            }
838
839            false
840        }
841
842        DragAction::Release {
843            position,
844            modifiers,
845        } => {
846            let mut consumed = false;
847
848            if let Some(session) = take_session() {
849                if let Some(frame) = dnd_frame() {
850                    dnd_finish(&frame, session, modifiers, position, true);
851                }
852                consumed = true;
853            }
854
855            set_mouse_down(None);
856            set_touch_down(None);
857
858            consumed
859        }
860
861        DragAction::Cancel => {
862            let mut consumed = false;
863            if let Some(session) = take_session() {
864                if let Some(frame) = dnd_frame() {
865                    dnd_finish(
866                        &frame,
867                        session,
868                        Modifiers::default(),
869                        Vec2::default(),
870                        false,
871                    );
872                }
873                consumed = true;
874            }
875            set_mouse_down(None);
876            set_touch_down(None);
877            consumed
878        }
879    }
880}
881
882/// Draw drag overlay indicator on the scene.
883/// `external_file_drag` enables orange styling for OS/browser file-drop overlays.
884pub fn overlay_drag_indicator(
885    scene: &mut Scene,
886    mouse_pos_px: (f32, f32),
887    external_file_drag: bool,
888) {
889    if !is_dragging() && !external_file_drag {
890        return;
891    }
892
893    let pos = Vec2 {
894        x: mouse_pos_px.0,
895        y: mouse_pos_px.1,
896    };
897
898    let frame = dnd_frame();
899    let Some(ref f) = frame else {
900        return;
901    };
902
903    let accent = if external_file_drag {
904        Color::from_hex("#FFAA00")
905    } else {
906        Color::from_hex("#44AAFF")
907    };
908
909    // Highlight best drop target under cursor
910    if let Some(tid) = dnd_target_id_at(f, pos)
911        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid)
912    {
913        use crate::units::UnitExt as _;
914        let r = 8.0.dp().to_px();
915        scene.nodes.push(SceneNode::Border {
916            rect: hit.rect,
917            brush: Brush::Solid(accent),
918            width: 2.0.dp().to_px(),
919            radius: [r; 4],
920        });
921    }
922
923    if external_file_drag {
924        draw_label_chip(scene, pos, "Drop files", accent, true);
925        return;
926    }
927
928    let Some(session) = current_drag_session() else {
929        return;
930    };
931    let ctx = DragPreviewCtx {
932        pointer: pos,
933        start_pointer: Vec2 {
934            x: session.start_px.0,
935            y: session.start_px.1,
936        },
937        source_rect: session.source_rect,
938        grab_offset: session.grab_offset,
939        payload: session.payload.clone(),
940    };
941
942    if let Some(preview) = session.preview {
943        preview(scene, &ctx);
944    } else {
945        draw_default_source_ghost(scene, &ctx, accent);
946    }
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use crate::input::PointerKind;
953    use crate::runtime::HitRegion;
954
955    #[test]
956    fn contexts_isolate_drag_sessions() {
957        let frame = Frame {
958            scene: Scene::default(),
959            hit_regions: vec![HitRegion {
960                id: 1,
961                rect: Rect {
962                    x: 0.0,
963                    y: 0.0,
964                    w: 100.0,
965                    h: 100.0,
966                },
967                on_drag_start: Some(Rc::new(|_| Some(drag_payload(7u32)))),
968                ..Default::default()
969            }],
970            semantics_nodes: Vec::new(),
971            focus_chain: Vec::new(),
972        };
973        let a = DndContext::default();
974        let b = DndContext::default();
975        {
976            let _guard = a.enter();
977            set_dnd_frame(Some(frame.clone()));
978            handle_drag_action(&DragAction::Press {
979                position: Vec2 { x: 10.0, y: 10.0 },
980                capture_id: 1,
981                kind: PointerKind::Mouse,
982                modifiers: Modifiers::default(),
983            });
984            handle_drag_action(&DragAction::Move {
985                position: Vec2 { x: 40.0, y: 40.0 },
986                modifiers: Modifiers::default(),
987            });
988            assert!(is_dragging());
989        }
990        {
991            let _guard = b.enter();
992            assert!(!is_dragging());
993            set_dnd_frame(Some(frame.clone()));
994        }
995        {
996            let _guard = a.enter();
997            assert!(is_dragging());
998            handle_drag_action(&DragAction::Cancel);
999            assert!(!is_dragging());
1000        }
1001    }
1002}