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