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/// Block-style convenience for [`Modifier::on_drop`] with a typed payload. The
60/// drop is accepted when the closure returns `true`; the typed payload is
61/// downcast before the closure is invoked.
62///
63/// ```ignore
64/// use repose_core::{Modifier, drag_and_drop_target};
65/// struct MyItem { id: i32 }
66/// let m = drag_and_drop_target(Modifier::new(), |_ev, item: &MyItem| {
67///     println!("got id {}", item.id);
68///     true
69/// });
70/// ```
71pub fn drag_and_drop_target<T, F>(mut modifier: crate::Modifier, on_drop: F) -> crate::Modifier
72where
73    T: 'static,
74    F: Fn(&DropEvent, &T) -> bool + 'static,
75{
76    modifier = modifier.on_drop(move |ev| match downcast_drag_payload::<T>(&ev.payload) {
77        Some(v) => on_drop(&ev, v),
78        None => false,
79    });
80    modifier
81}
82
83#[derive(Clone, Debug)]
84pub struct DragStart {
85    pub source_id: u64,
86    pub position: Vec2,
87    pub modifiers: Modifiers,
88}
89
90#[derive(Clone, Debug)]
91pub struct DragOver {
92    pub source_id: u64,
93    pub target_id: u64,
94    pub position: Vec2,
95    pub modifiers: Modifiers,
96    pub payload: DragPayload,
97}
98
99#[derive(Clone, Debug)]
100pub struct DropEvent {
101    pub source_id: u64,
102    pub target_id: u64,
103    pub position: Vec2,
104    pub modifiers: Modifiers,
105    pub payload: DragPayload,
106}
107
108/// Sent to the drag source when the drag ends (drop or cancel).
109#[derive(Clone, Copy, Debug)]
110pub struct DragEnd {
111    pub accepted: bool,
112}
113
114/// A single dropped file descriptor.
115/// - On desktop: `path` is `Some(PathBuf)`.
116/// - On web: `path` is usually `None` (browser doesn't expose local paths).
117#[derive(Clone, Debug)]
118pub struct DroppedFile {
119    pub name: String,
120    pub path: Option<PathBuf>,
121}
122
123/// Payload type for file drag/drop coming from the OS/browser.
124#[derive(Clone, Debug)]
125pub struct DroppedFiles {
126    pub files: Vec<DroppedFile>,
127}
128
129/// Tracks an active drag session (internal widget-to-widget DnD).
130#[derive(Clone, Debug)]
131pub struct DragSession {
132    pub source_id: u64,
133    pub payload: DragPayload,
134    pub start_px: (f32, f32),
135    pub over_id: Option<u64>,
136}
137
138#[derive(Clone)]
139struct MouseDownState {
140    position: Vec2,
141    capture_id: u64,
142}
143
144#[derive(Clone)]
145struct TouchDownState {
146    time: Instant,
147    position: Vec2,
148    capture_id: u64,
149    long_press_pending: bool,
150}
151
152const LONG_PRESS_MS: u128 = 400;
153
154thread_local! {
155    static DND_FRAME: RefCell<Option<Frame>> = const { RefCell::new(None) };
156    static DND_SCALE: RefCell<f32> = const { RefCell::new(1.0) };
157    static DND_SESSION: RefCell<Option<DragSession>> = const { RefCell::new(None) };
158    static DND_MOUSE_DOWN: RefCell<Option<MouseDownState>> = const { RefCell::new(None) };
159    static DND_TOUCH_DOWN: RefCell<Option<TouchDownState>> = const { RefCell::new(None) };
160}
161
162/// Set the current frame for DnD hit-testing. Called by platform after each render.
163pub fn set_dnd_frame(frame: Option<Frame>) {
164    DND_FRAME.with(|f| *f.borrow_mut() = frame);
165}
166
167/// Set the display scale for DnD slop calculation.
168pub fn set_dnd_scale(scale: f32) {
169    DND_SCALE.with(|s| *s.borrow_mut() = scale);
170}
171
172/// Check if a drag session is currently active.
173pub fn is_dragging() -> bool {
174    DND_SESSION.with(|s| s.borrow().is_some())
175}
176
177fn touch_slop_px(scale: f32) -> f32 {
178    6.0 * scale
179}
180
181fn hit_index_by_id(frame: &Frame, id: u64) -> Option<usize> {
182    frame.hit_regions.iter().position(|h| h.id == id)
183}
184
185fn is_dnd_target(hit: &HitRegion) -> bool {
186    hit.on_drop.is_some()
187        || hit.on_drag_enter.is_some()
188        || hit.on_drag_over.is_some()
189        || hit.on_drag_leave.is_some()
190}
191
192pub fn dnd_target_id_at(frame: &Frame, pos: Vec2) -> Option<u64> {
193    frame
194        .hit_regions
195        .iter()
196        .rev()
197        .filter(|h| h.rect.contains(pos))
198        .find(|h| is_dnd_target(h))
199        .map(|h| h.id)
200}
201
202fn dnd_update_over(frame: &Frame, session: &mut DragSession, modifiers: Modifiers, pos: Vec2) {
203    let new_over = dnd_target_id_at(frame, pos);
204
205    if new_over != session.over_id {
206        if let Some(prev) = session.over_id {
207            if let Some(i) = hit_index_by_id(frame, prev) {
208                if let Some(cb) = &frame.hit_regions[i].on_drag_leave {
209                    cb(DragOver {
210                        source_id: session.source_id,
211                        target_id: prev,
212                        position: pos,
213                        modifiers,
214                        payload: session.payload.clone(),
215                    });
216                }
217            }
218        }
219
220        if let Some(now) = new_over {
221            if let Some(i) = hit_index_by_id(frame, now) {
222                if let Some(cb) = &frame.hit_regions[i].on_drag_enter {
223                    cb(DragOver {
224                        source_id: session.source_id,
225                        target_id: now,
226                        position: pos,
227                        modifiers,
228                        payload: session.payload.clone(),
229                    });
230                }
231            }
232        }
233
234        session.over_id = new_over;
235    }
236
237    if let Some(over) = session.over_id {
238        if let Some(i) = hit_index_by_id(frame, over) {
239            if let Some(cb) = &frame.hit_regions[i].on_drag_over {
240                cb(DragOver {
241                    source_id: session.source_id,
242                    target_id: over,
243                    position: pos,
244                    modifiers,
245                    payload: session.payload.clone(),
246                });
247            }
248        }
249    }
250}
251
252/// Finish a drag-and-drop session.
253fn dnd_finish(
254    frame: &Frame,
255    session: DragSession,
256    modifiers: Modifiers,
257    pos: Vec2,
258    accept_if_possible: bool,
259) -> bool {
260    let mut accepted = false;
261    if accept_if_possible {
262        let drop_target = dnd_target_id_at(frame, pos);
263        if let Some(tid) = drop_target {
264            if let Some(i) = hit_index_by_id(frame, tid) {
265                if let Some(cb) = &frame.hit_regions[i].on_drop {
266                    accepted = cb(DropEvent {
267                        source_id: session.source_id,
268                        target_id: tid,
269                        position: pos,
270                        modifiers,
271                        payload: session.payload.clone(),
272                    });
273                }
274            }
275        }
276    }
277
278    if let Some(i) = hit_index_by_id(frame, session.source_id) {
279        if let Some(cb) = &frame.hit_regions[i].on_drag_end {
280            cb(DragEnd { accepted });
281        }
282    }
283
284    accepted
285}
286
287fn initiate_drag(
288    frame: &Frame,
289    capture_id: u64,
290    start_pos: Vec2,
291    current_pos: Vec2,
292    modifiers: Modifiers,
293) -> bool {
294    let Some(i) = hit_index_by_id(frame, capture_id) else {
295        return false;
296    };
297    let Some(cb) = &frame.hit_regions[i].on_drag_start else {
298        return false;
299    };
300
301    let payload = cb(DragStart {
302        source_id: capture_id,
303        position: current_pos,
304        modifiers,
305    });
306    let Some(payload) = payload else {
307        return false;
308    };
309
310    DND_SESSION.with(|s| {
311        *s.borrow_mut() = Some(DragSession {
312            source_id: capture_id,
313            payload,
314            start_px: (start_pos.x, start_pos.y),
315            over_id: None,
316        });
317    });
318    true
319}
320
321/// Handle a DragAction from the platform. Returns true if the action was consumed.
322pub fn handle_drag_action(action: &DragAction) -> bool {
323    let scale = DND_SCALE.with(|s| *s.borrow());
324    let slop = touch_slop_px(scale);
325
326    match *action {
327        DragAction::Press {
328            position,
329            capture_id,
330            kind,
331            ..
332        } => {
333            match kind {
334                PointerKind::Mouse => {
335                    DND_MOUSE_DOWN.with(|m| {
336                        *m.borrow_mut() = Some(MouseDownState {
337                            position,
338                            capture_id,
339                        });
340                    });
341                }
342                _ => {
343                    // Touch (or pen/unknown): start long-press timer
344                    DND_TOUCH_DOWN.with(|t| {
345                        *t.borrow_mut() = Some(TouchDownState {
346                            time: web_time::Instant::now(),
347                            position,
348                            capture_id,
349                            long_press_pending: true,
350                        });
351                    });
352                }
353            }
354            false
355        }
356
357        DragAction::Move {
358            position,
359            modifiers,
360        } => {
361            // If already dragging, update
362            if DND_SESSION.with(|s| s.borrow().is_some()) {
363                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
364                    DND_SESSION.with(|s| {
365                        if let Some(ref mut session) = *s.borrow_mut() {
366                            dnd_update_over(&frame, session, modifiers, position);
367                        }
368                    });
369                }
370                return true;
371            }
372
373            // Mouse: try drag initiation (drag past slop)
374            if let Some(down) = DND_MOUSE_DOWN.with(|m| m.borrow().clone()) {
375                let dx = position.x - down.position.x;
376                let dy = position.y - down.position.y;
377                let dist = (dx * dx + dy * dy).sqrt();
378                if dist >= slop {
379                    if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
380                        if initiate_drag(
381                            &frame,
382                            down.capture_id,
383                            down.position,
384                            position,
385                            modifiers,
386                        ) {
387                            // Update over immediately
388                            DND_SESSION.with(|s| {
389                                if let Some(ref mut session) = *s.borrow_mut() {
390                                    dnd_update_over(&frame, session, modifiers, position);
391                                }
392                            });
393                            DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
394                            return true;
395                        }
396                    }
397                    // Widget doesn't support drag - try mouse down again next time
398                    // (actually, clear it so we don't retry on every move)
399                    DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
400                }
401                return true; // consumed: mouse is pressed, don't fall through to scroll
402            }
403
404            // Touch: try long-press initiation
405            if let Some(touch) = DND_TOUCH_DOWN.with(|t| t.borrow().clone()) {
406                if touch.long_press_pending {
407                    let elapsed_ms = (Instant::now() - touch.time).as_millis() as u128;
408                    let dx = position.x - touch.position.x;
409                    let dy = position.y - touch.position.y;
410                    let dist = (dx * dx + dy * dy).sqrt();
411
412                    if elapsed_ms >= LONG_PRESS_MS && dist <= slop {
413                        if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
414                            if initiate_drag(
415                                &frame,
416                                touch.capture_id,
417                                touch.position,
418                                position,
419                                modifiers,
420                            ) {
421                                DND_SESSION.with(|s| {
422                                    if let Some(ref mut session) = *s.borrow_mut() {
423                                        dnd_update_over(&frame, session, modifiers, position);
424                                    }
425                                });
426                                DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
427                                return true;
428                            }
429                            // Widget doesn't support drag - cancel long press
430                            DND_TOUCH_DOWN.with(|t| {
431                                if let Some(ref mut td) = *t.borrow_mut() {
432                                    td.long_press_pending = false;
433                                }
434                            });
435                        }
436                    }
437                    if dist > slop {
438                        DND_TOUCH_DOWN.with(|t| {
439                            if let Some(ref mut td) = *t.borrow_mut() {
440                                td.long_press_pending = false;
441                            }
442                        });
443                    }
444                }
445                // Only consume if still waiting for long-press (within slop, timer not yet expired).
446                // If long-press was cancelled (moved past slop), let scroll handle the event.
447                let still_pending = DND_TOUCH_DOWN.with(|t| {
448                    t.borrow().as_ref().map(|td| td.long_press_pending).unwrap_or(false)
449                });
450                if still_pending {
451                    return true;
452                }
453            }
454
455            false
456        }
457
458        DragAction::Release {
459            position,
460            modifiers,
461        } => {
462            let mut consumed = false;
463
464            if let Some(session) = DND_SESSION.with(|s| s.borrow_mut().take()) {
465                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
466                    dnd_finish(&frame, session, modifiers, position, true);
467                }
468                consumed = true;
469            }
470
471            DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
472            DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
473
474            consumed
475        }
476
477        DragAction::Cancel => {
478            let mut consumed = false;
479            if let Some(session) = DND_SESSION.with(|s| s.borrow_mut().take()) {
480                if let Some(frame) = DND_FRAME.with(|f| f.borrow().clone()) {
481                    dnd_finish(
482                        &frame,
483                        session,
484                        Modifiers::default(),
485                        Vec2::default(),
486                        false,
487                    );
488                }
489                consumed = true;
490            }
491            DND_MOUSE_DOWN.with(|m| *m.borrow_mut() = None);
492            DND_TOUCH_DOWN.with(|t| *t.borrow_mut() = None);
493            consumed
494        }
495    }
496}
497
498/// Draw drag overlay indicator on the scene.
499/// `external_file_drag` enables orange styling for OS/browser file-drop overlays.
500pub fn overlay_drag_indicator(
501    scene: &mut Scene,
502    mouse_pos_px: (f32, f32),
503    external_file_drag: bool,
504) {
505    if !is_dragging() && !external_file_drag {
506        return;
507    }
508
509    let pos = Vec2 {
510        x: mouse_pos_px.0,
511        y: mouse_pos_px.1,
512    };
513
514    let frame = DND_FRAME.with(|f| f.borrow().clone());
515    let Some(ref f) = frame else {
516        return;
517    };
518
519    let color = if external_file_drag {
520        Color::from_hex("#FFAA00")
521    } else {
522        Color::from_hex("#44AAFF")
523    };
524
525    // Highlight best drop target under cursor
526    if let Some(tid) = dnd_target_id_at(f, pos)
527        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid)
528    {
529        let r = crate::locals::dp_to_px(8.0);
530        scene.nodes.push(SceneNode::Border {
531            rect: hit.rect,
532            color,
533            width: crate::locals::dp_to_px(2.0),
534            radius: [r; 4],
535        });
536    }
537
538    // Cursor badge
539    let badge = Rect {
540        x: pos.x + crate::locals::dp_to_px(12.0),
541        y: pos.y + crate::locals::dp_to_px(12.0),
542        w: crate::locals::dp_to_px(110.0),
543        h: crate::locals::dp_to_px(24.0),
544    };
545
546    let bg = if external_file_drag {
547        Color::from_hex("#FFAA0077")
548    } else {
549        Color::from_hex("#44AAFF77")
550    };
551
552    let r = crate::locals::dp_to_px(8.0);
553    scene.nodes.push(SceneNode::Rect {
554        rect: badge,
555        brush: Brush::Solid(bg),
556        radius: [r; 4],
557    });
558    scene.nodes.push(SceneNode::Text {
559        rect: Rect {
560            x: badge.x + crate::locals::dp_to_px(8.0),
561            y: badge.y + crate::locals::dp_to_px(6.0),
562            w: 0.0,
563            h: crate::locals::dp_to_px(14.0),
564        },
565        text: Arc::<str>::from(" "),
566        color: Color::WHITE,
567        size: crate::locals::dp_to_px(12.0),
568        font_family: None,
569        text_align: TextAlign::Unspecified,
570        font_weight: FontWeight::NORMAL,
571        font_style: FontStyle::Normal,
572        text_decoration: TextDecoration::default(),
573        letter_spacing: 0.0,
574        line_height: 0.0,
575        extra_style: Default::default(),
576        url: None,
577    });
578}