Skip to main content

frus_widgets/
dragdrop.rs

1//! [`Draggable`] and [`DragTarget`]: **picking a thing up and dropping it
2//! somewhere else** — the general pair, underneath the reordering that
3//! [`crate::Table`] and [`crate::Kanban`] already do for themselves.
4//!
5//! Reordering answers a narrower question: *where in this list?* Its answer is an
6//! index, and both ends of the gesture belong to the same widget. Drag and drop
7//! answers a wider one: *which thing, onto which other thing?* The two ends are
8//! unrelated widgets that need not know of each other, so what travels between them
9//! is a **payload** the application chooses — a `u64` it can map back to whatever it
10//! means.
11//!
12//! ```ignore
13//! Draggable::new(card).payload(task.id)
14//! DragTarget::new(column).on_drop(|payload| Msg::MoveTask(payload, column_id))
15//! ```
16//!
17//! **A draggable yields to a scrollable underneath it.** A widget that took every
18//! drag inside a list would silently stop that list scrolling, which is a worse
19//! failure than not lifting: the gesture that broke is the one the user makes a
20//! hundred times an hour. So inside a scrollable, the way to lift is
21//! [`Draggable::long_press`] — a gesture the scroll cannot claim, because a finger
22//! that holds still is not scrolling. Outside one, a plain drag lifts straight away.
23//!
24//! **Files from outside the application land on the same targets.** On the desktop,
25//! files dragged onto the window from the system's file manager are offered to the
26//! target under the pointer, which lights up as it does for an item from inside and
27//! receives their paths on the drop — see [`DragTarget::on_files`].
28
29use std::path::PathBuf;
30
31use frus_core::{Color, Rect, Scene};
32use frus_layout::Style;
33
34use crate::interaction::{Status, WidgetId};
35use crate::theme::Theme;
36use crate::widget::{sizing_of, Widget};
37
38/// The opacity left behind where a lifted item was.
39const GHOSTED_OPACITY: f32 = 0.35;
40/// The opacity of the highlight painted over a target that would accept the drop.
41const TARGET_ALPHA: f32 = 0.12;
42/// The thickness of that highlight's outline, in px.
43const TARGET_OUTLINE: f32 = 2.0;
44
45/// A widget that can be picked up, as the shell sees it.
46#[derive(Copy, Clone, Debug, PartialEq)]
47pub struct DragSource {
48    /// The widget's identity.
49    pub id: WidgetId,
50    /// Where it sits, in absolute coordinates.
51    pub rect: Rect,
52    /// What it carries.
53    pub payload: u64,
54}
55
56/// A widget that can be dropped onto, as the shell sees it.
57#[derive(Copy, Clone, Debug, PartialEq)]
58pub struct DropZone {
59    /// The widget's identity.
60    pub id: WidgetId,
61    /// Where it sits, in absolute coordinates.
62    pub rect: Rect,
63}
64
65/// A widget that can be **picked up** and dropped on a [`DragTarget`].
66///
67/// It carries a `payload`: a number the application chooses and gets back on the
68/// drop, so the two ends of the gesture never have to know each other's types. What
69/// floats under the pointer is the widget's own appearance, lifted out of the frame
70/// — nothing to build twice, and nothing that can drift from what is on screen.
71pub struct Draggable<Msg = crate::callback::Callback> {
72    payload: u64,
73    long_press: bool,
74    ghost_opacity: f32,
75    on_dropped: Option<Box<dyn Fn(bool) -> Msg>>,
76    children: Vec<Box<dyn Widget<Msg>>>,
77}
78
79impl<Msg> Draggable<Msg> {
80    /// Makes `child` draggable, carrying `0` until [`Draggable::payload`] says
81    /// otherwise.
82    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
83        Self {
84            payload: 0,
85            long_press: false,
86            ghost_opacity: GHOSTED_OPACITY,
87            on_dropped: None,
88            children: vec![Box::new(child)],
89        }
90    }
91
92    /// What this item carries — an id, an index, anything the application can map
93    /// back. It is handed to the target's `on_drop` untouched.
94    pub fn payload(mut self, payload: u64) -> Self {
95        self.payload = payload;
96        self
97    }
98
99    /// Lift on a **long press** rather than on the first movement.
100    ///
101    /// This is what a draggable inside a list needs. Both gestures start with a finger
102    /// going down on the same row, and only one of them can win; resolving it by "who
103    /// is on top" would hand every drag to the item and leave the list unscrollable.
104    /// A hold is the one signal the scroll cannot claim — a finger that stays still is
105    /// not scrolling — so it is the hold that lifts.
106    pub fn long_press(mut self) -> Self {
107        self.long_press = true;
108        self
109    }
110
111    /// How solid the item left behind looks while its copy is being dragged. `1.0`
112    /// leaves it untouched, `0.0` makes the lift look like a removal.
113    pub fn ghost_opacity(mut self, opacity: f32) -> Self {
114        self.ghost_opacity = opacity.clamp(0.0, 1.0);
115        self
116    }
117
118    /// The message sent when the drag ends, `true` if a target took it.
119    ///
120    /// A refused drop is worth knowing about: it is the moment to say why, and the
121    /// only difference between "nothing happened" and "that is not allowed here".
122    pub fn on_dropped<R: crate::callback::IntoMsg<Msg>>(
123        mut self,
124        message: impl Fn(bool) -> R + 'static,
125    ) -> Self {
126        self.on_dropped = Some(Box::new(crate::callback::handler1(message)));
127        self
128    }
129}
130
131impl<Msg: Clone> Widget<Msg> for Draggable<Msg> {
132    fn style(&self) -> Style {
133        sizing_of(
134            self.children
135                .first()
136                .map(|child| child.style())
137                .unwrap_or_default(),
138        )
139    }
140
141    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
142        &self.children
143    }
144
145    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
146        // Nothing of its own: the child is the item.
147    }
148
149    fn on_click(&self) -> Option<Msg> {
150        None
151    }
152
153    // The **structural** questions, forwarded from the child. A wrapper that answered
154    // them for itself would change how its content is laid out: a `Dismissible` is a
155    // layout leaf, so wrapping it in an ordinary container leaves it with no content
156    // size and it collapses to nothing on the wrapper's main axis — silently, since
157    // neither draws a box of its own. Found on a device: a task row wrapped for
158    // dragging vanished while its checkbox still counted. The same lesson as `Keyed`,
159    // which had it the other way round.
160    fn stack(&self) -> bool {
161        self.children.first().is_some_and(|child| child.stack())
162    }
163
164    fn continuous(&self) -> bool {
165        self.children
166            .first()
167            .is_some_and(|child| child.continuous())
168    }
169
170    fn drag_payload(&self) -> Option<u64> {
171        Some(self.payload)
172    }
173
174    fn drag_needs_long_press(&self) -> bool {
175        self.long_press
176    }
177
178    fn drag_ghost_opacity(&self) -> f32 {
179        self.ghost_opacity
180    }
181
182    fn on_dropped(&self, accepted: bool) -> Option<Msg> {
183        self.on_dropped.as_ref().map(|make| make(accepted))
184    }
185}
186
187/// Which sets of dropped files a target takes; see [`DragTarget::accepts_files`].
188type FilesPredicate = Box<dyn Fn(&[PathBuf]) -> bool>;
189
190/// A widget that can be **dropped onto**.
191///
192/// By default it takes anything. `accepts` narrows that — a column that only takes
193/// tasks of its own project, say — and a target that refuses is not highlighted and
194/// not offered the drop, so the answer is visible before the finger lifts rather
195/// than after.
196///
197/// It can also take **files dragged onto the window** from outside the application —
198/// from the system's file manager, say — once it is given [`DragTarget::on_files`]:
199///
200/// ```ignore
201/// DragTarget::new(drop_area)
202///     .on_files(Msg::Attach)
203///     .accepts_files(|paths| paths.iter().all(|p| p.extension().is_some_and(|e| e == "png")))
204/// ```
205pub struct DragTarget<Msg = crate::callback::Callback> {
206    accepts: Option<Box<dyn Fn(u64) -> bool>>,
207    on_drop: Option<Box<dyn Fn(u64) -> Msg>>,
208    accepts_files: Option<FilesPredicate>,
209    on_files: Option<Box<dyn Fn(Vec<PathBuf>) -> Msg>>,
210    highlight: Option<Color>,
211    children: Vec<Box<dyn Widget<Msg>>>,
212}
213
214impl<Msg> DragTarget<Msg> {
215    /// Makes `child` a drop target.
216    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
217        Self {
218            accepts: None,
219            on_drop: None,
220            accepts_files: None,
221            on_files: None,
222            highlight: None,
223            children: vec![Box::new(child)],
224        }
225    }
226
227    /// The message sent when an accepted item is dropped here, given its payload.
228    pub fn on_drop<R: crate::callback::IntoMsg<Msg>>(
229        mut self,
230        message: impl Fn(u64) -> R + 'static,
231    ) -> Self {
232        self.on_drop = Some(Box::new(crate::callback::handler1(message)));
233        self
234    }
235
236    /// Which payloads this target takes; everything, by default.
237    pub fn accepts(mut self, predicate: impl Fn(u64) -> bool + 'static) -> Self {
238        self.accepts = Some(Box::new(predicate));
239        self
240    }
241
242    /// The message sent when **files** dragged onto the window from outside the
243    /// application are dropped here, given their paths, in the order the system
244    /// listed them.
245    ///
246    /// While the files are held over this target it shows the same "drop it here"
247    /// highlight as for an item from inside. A target given this and neither
248    /// [`DragTarget::on_drop`] nor [`DragTarget::accepts`] takes **only** files: it is
249    /// a place for files, and lighting up under a card being moved would say
250    /// otherwise.
251    ///
252    /// Files are delivered on the desktop. On the web and on mobile, where nothing is
253    /// dragged onto the window this way, the message is never sent.
254    pub fn on_files<R: crate::callback::IntoMsg<Msg>>(
255        mut self,
256        message: impl Fn(Vec<PathBuf>) -> R + 'static,
257    ) -> Self {
258        self.on_files = Some(Box::new(crate::callback::handler1(message)));
259        self
260    }
261
262    /// Which sets of files this target takes; every set, by default, once it has
263    /// [`DragTarget::on_files`]. The predicate sees every path being dragged, so a
264    /// target that wants images can refuse a drag that holds anything else — and is
265    /// then not highlighted, so the answer shows before the button is let go.
266    pub fn accepts_files(mut self, predicate: impl Fn(&[PathBuf]) -> bool + 'static) -> Self {
267        self.accepts_files = Some(Box::new(predicate));
268        self
269    }
270
271    /// The colour of the "drop it here" highlight. Unset, it is the theme's accent;
272    /// set it to [`Color::TRANSPARENT`] to draw none and paint your own from the
273    /// child.
274    pub fn highlight(mut self, color: Color) -> Self {
275        self.highlight = Some(color);
276        self
277    }
278}
279
280impl<Msg: Clone> Widget<Msg> for DragTarget<Msg> {
281    fn style(&self) -> Style {
282        sizing_of(
283            self.children
284                .first()
285                .map(|child| child.style())
286                .unwrap_or_default(),
287        )
288    }
289
290    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
291        &self.children
292    }
293
294    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
295        // Painted **under** the child, since `paint` runs before the children are
296        // walked: a wash over the target's own background rather than over its text.
297        if !status.drag_over {
298            return;
299        }
300        let color = self.highlight.unwrap_or(theme.primary);
301        if color.a <= 0.0 {
302            return;
303        }
304        scene.draw_rect(
305            bounds,
306            color.fade(TARGET_ALPHA),
307            theme.radius,
308            TARGET_OUTLINE,
309            color,
310        );
311    }
312
313    fn on_click(&self) -> Option<Msg> {
314        None
315    }
316
317    // The **structural** questions, forwarded from the child. A wrapper that answered
318    // them for itself would change how its content is laid out: a `Dismissible` is a
319    // layout leaf, so wrapping it in an ordinary container leaves it with no content
320    // size and it collapses to nothing on the wrapper's main axis — silently, since
321    // neither draws a box of its own. Found on a device: a task row wrapped for
322    // dragging vanished while its checkbox still counted. The same lesson as `Keyed`,
323    // which had it the other way round.
324    fn stack(&self) -> bool {
325        self.children.first().is_some_and(|child| child.stack())
326    }
327
328    fn continuous(&self) -> bool {
329        self.children
330            .first()
331            .is_some_and(|child| child.continuous())
332    }
333
334    fn drop_zone(&self) -> bool {
335        true
336    }
337
338    fn accepts_drag(&self, payload: u64) -> bool {
339        match &self.accepts {
340            Some(predicate) => predicate(payload),
341            // A place for files only, unless it said it takes items too.
342            None => self.on_drop.is_some() || self.on_files.is_none(),
343        }
344    }
345
346    fn on_drop(&self, payload: u64) -> Option<Msg> {
347        self.on_drop.as_ref().map(|make| make(payload))
348    }
349
350    fn accepts_files(&self, paths: &[PathBuf]) -> bool {
351        if self.on_files.is_none() || paths.is_empty() {
352            return false;
353        }
354        match &self.accepts_files {
355            Some(predicate) => predicate(paths),
356            None => true,
357        }
358    }
359
360    fn on_files_dropped(&self, paths: Vec<PathBuf>) -> Option<Msg> {
361        self.on_files.as_ref().map(|make| make(paths))
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::{build_ui, Container, Flex, Runtime};
369    use frus_core::{Point, Primitive, Size};
370
371    #[derive(Clone, Debug, PartialEq)]
372    enum Msg {
373        Dropped(u64),
374        Ended(bool),
375        Files(Vec<PathBuf>),
376    }
377
378    fn tree() -> Flex<Msg> {
379        Flex::column()
380            .width(200.0)
381            .child(
382                Draggable::new(Container::new().width(100.0).height(40.0))
383                    .payload(7)
384                    .on_dropped(Msg::Ended),
385            )
386            .child(
387                DragTarget::new(Container::new().width(200.0).height(60.0))
388                    .on_drop(Msg::Dropped)
389                    .accepts(|payload| payload % 2 == 1),
390            )
391    }
392
393    fn ui(runtime: &Runtime) -> crate::Ui<Msg> {
394        build_ui(&tree(), Size::new(200.0, 200.0), runtime, &Theme::dark())
395    }
396
397    #[test]
398    fn a_draggable_registers_what_it_carries_and_where_it_is() {
399        let runtime = Runtime::default();
400        let ui = ui(&runtime);
401        let source = ui
402            .drag_source_at(Point::new(50.0, 20.0))
403            .expect("a source under the pointer");
404        assert_eq!(source.payload, 7);
405        assert!((source.rect.height - 40.0).abs() < 0.5);
406        // Below the item there is no source.
407        assert!(ui.drag_source_at(Point::new(50.0, 70.0)).is_none());
408    }
409
410    #[test]
411    fn a_draggable_says_how_it_wants_to_be_lifted() {
412        let plain = Draggable::<Msg>::new(Container::new());
413        assert!(!Widget::<Msg>::drag_needs_long_press(&plain));
414        let held = Draggable::<Msg>::new(Container::new()).long_press();
415        assert!(Widget::<Msg>::drag_needs_long_press(&held));
416    }
417
418    #[test]
419    fn a_draggable_row_inside_a_column_still_paints() {
420        use crate::{keyed, Dismissible, Flex};
421        let red = Color::rgb(1.0, 0.0, 0.0);
422        let row = Dismissible::<Msg>::new(Container::new().color(red).height(62.0))
423            .height(62.0)
424            .on_dismiss(Msg::Dropped(1));
425        let root = Flex::<Msg>::column()
426            .width(300.0)
427            .child(keyed(1u64, Draggable::new(row).payload(1).long_press()));
428        let runtime = Runtime::default();
429        let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
430        let painted: Vec<Rect> = ui
431            .scene()
432            .primitives()
433            .iter()
434            .filter_map(|p| match p {
435                Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
436                    Some(*rect)
437                }
438                _ => None,
439            })
440            .collect();
441        assert!(
442            !painted.is_empty(),
443            "the row vanished ({} primitives in all)",
444            ui.scene().primitives().len()
445        );
446        assert!((painted[0].height - 62.0).abs() < 1.0, "{:?}", painted[0]);
447    }
448
449    /// A target wrapping ordinary content keeps that content's height — it must not
450    /// become a layout leaf just because some other child would need it to be.
451    #[test]
452    fn a_target_around_ordinary_content_is_as_tall_as_that_content() {
453        use crate::Flex;
454        let red = Color::rgb(1.0, 0.0, 0.0);
455        let root = Flex::<Msg>::column().width(300.0).child(DragTarget::new(
456            Container::new()
457                .padding(12.0)
458                .color(red)
459                .child(Container::new().height(20.0)),
460        ));
461        let runtime = Runtime::default();
462        let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
463        let painted = ui
464            .scene()
465            .primitives()
466            .iter()
467            .find_map(|p| match p {
468                Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
469                    Some(*rect)
470                }
471                _ => None,
472            })
473            .expect("the target's content");
474        assert!(
475            (painted.height - 44.0).abs() < 1.0,
476            "20 px of content plus 12 either side: {painted:?}"
477        );
478    }
479
480    #[test]
481    fn a_target_is_found_by_where_it_is() {
482        let runtime = Runtime::default();
483        let ui = ui(&runtime);
484        assert!(ui.drop_zone_at(Point::new(100.0, 60.0)).is_some());
485        assert!(ui.drop_zone_at(Point::new(100.0, 10.0)).is_none());
486    }
487
488    #[test]
489    fn a_target_refuses_what_it_says_it_refuses() {
490        let target = DragTarget::<Msg>::new(Container::new()).accepts(|payload| payload % 2 == 1);
491        assert!(Widget::<Msg>::accepts_drag(&target, 7));
492        assert!(!Widget::<Msg>::accepts_drag(&target, 8));
493        // With no predicate, everything is welcome.
494        let open = DragTarget::<Msg>::new(Container::new());
495        assert!(Widget::<Msg>::accepts_drag(&open, 8));
496    }
497
498    #[test]
499    fn the_payload_reaches_the_drop_untouched() {
500        // Spelled through the trait: the builder of the same name would win otherwise.
501        let target = DragTarget::new(Container::new()).on_drop(Msg::Dropped);
502        assert_eq!(Widget::<Msg>::on_drop(&target, 7), Some(Msg::Dropped(7)));
503        // A target with no handler is still a target; it just has nothing to say.
504        let silent = DragTarget::<Msg>::new(Container::new());
505        assert_eq!(Widget::<Msg>::on_drop(&silent, 7), None);
506    }
507
508    #[test]
509    fn a_refused_drop_is_reported_as_such() {
510        let source = Draggable::new(Container::new()).on_dropped(Msg::Ended);
511        assert_eq!(
512            Widget::<Msg>::on_dropped(&source, true),
513            Some(Msg::Ended(true))
514        );
515        assert_eq!(
516            Widget::<Msg>::on_dropped(&source, false),
517            Some(Msg::Ended(false))
518        );
519    }
520
521    #[test]
522    fn a_target_is_only_highlighted_while_a_drag_is_over_it() {
523        let quiet = Runtime::default();
524        let plain = ui(&quiet).scene().primitives().len();
525
526        let mut hovered = Runtime::default();
527        let zone = ui(&quiet)
528            .drop_zone_at(Point::new(100.0, 60.0))
529            .expect("the target");
530        hovered.drag_over = Some(zone.id);
531        let lit = ui(&hovered);
532        assert_eq!(
533            lit.scene().primitives().len(),
534            plain + 1,
535            "exactly one highlight, and only when hovered"
536        );
537        // And it covers the target, rather than being a stray mark somewhere.
538        let highlight = lit
539            .scene()
540            .primitives()
541            .iter()
542            .filter_map(|p| match p {
543                Primitive::Rect { rect, .. } if (rect.height - 60.0).abs() < 0.5 => Some(*rect),
544                _ => None,
545            })
546            .next()
547            .expect("the highlight");
548        assert!((highlight.width - 200.0).abs() < 0.5, "{highlight:?}");
549    }
550    #[test]
551    fn a_target_takes_no_files_unless_it_says_so() {
552        let paths = vec![PathBuf::from("a.txt")];
553        let plain = DragTarget::new(Container::new()).on_drop(Msg::Dropped);
554        assert!(!Widget::<Msg>::accepts_files(&plain, &paths));
555        assert_eq!(Widget::<Msg>::on_files_dropped(&plain, paths.clone()), None);
556        let files = DragTarget::new(Container::new()).on_files(Msg::Files);
557        assert!(Widget::<Msg>::accepts_files(&files, &paths));
558        // Nothing dragged is nothing to take.
559        assert!(!Widget::<Msg>::accepts_files(&files, &[]));
560    }
561
562    #[test]
563    fn the_paths_reach_the_drop_untouched_and_in_order() {
564        let paths = vec![PathBuf::from("b.png"), PathBuf::from("a.png")];
565        let target = DragTarget::new(Container::new()).on_files(Msg::Files);
566        assert_eq!(
567            Widget::<Msg>::on_files_dropped(&target, paths.clone()),
568            Some(Msg::Files(paths))
569        );
570    }
571
572    #[test]
573    fn a_target_refuses_the_files_it_says_it_refuses() {
574        let images = DragTarget::new(Container::new())
575            .on_files(Msg::Files)
576            .accepts_files(|paths| {
577                paths
578                    .iter()
579                    .all(|p| p.extension().is_some_and(|e| e == "png"))
580            });
581        let png = [PathBuf::from("a.png"), PathBuf::from("b.png")];
582        let mixed = [PathBuf::from("a.png"), PathBuf::from("b.txt")];
583        assert!(Widget::<Msg>::accepts_files(&images, &png));
584        assert!(!Widget::<Msg>::accepts_files(&images, &mixed));
585    }
586
587    #[test]
588    fn a_target_for_files_only_turns_items_away() {
589        let files = DragTarget::new(Container::new()).on_files(Msg::Files);
590        assert!(!Widget::<Msg>::accepts_drag(&files, 7));
591        // Unless it takes items as well, or says which.
592        let both = DragTarget::new(Container::new())
593            .on_files(Msg::Files)
594            .on_drop(Msg::Dropped);
595        assert!(Widget::<Msg>::accepts_drag(&both, 7));
596        let picky = DragTarget::new(Container::new())
597            .on_files(Msg::Files)
598            .accepts(|payload| payload == 7);
599        assert!(Widget::<Msg>::accepts_drag(&picky, 7));
600        // And a target with neither handler stays what it was: open to anything.
601        let open = DragTarget::<Msg>::new(Container::new());
602        assert!(Widget::<Msg>::accepts_drag(&open, 7));
603    }
604
605    #[test]
606    fn a_wrapped_target_still_takes_files() {
607        let paths = vec![PathBuf::from("a.txt")];
608        let wrapped = crate::keyed(1u64, DragTarget::new(Container::new()).on_files(Msg::Files));
609        assert!(Widget::<Msg>::accepts_files(&wrapped, &paths));
610        assert_eq!(
611            Widget::<Msg>::on_files_dropped(&wrapped, paths.clone()),
612            Some(Msg::Files(paths))
613        );
614    }
615}