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
24use frus_core::{Color, Rect, Scene};
25use frus_layout::Style;
26
27use crate::interaction::{Status, WidgetId};
28use crate::theme::Theme;
29use crate::widget::{sizing_of, Widget};
30
31/// The opacity left behind where a lifted item was.
32const GHOSTED_OPACITY: f32 = 0.35;
33/// The opacity of the highlight painted over a target that would accept the drop.
34const TARGET_ALPHA: f32 = 0.12;
35/// The thickness of that highlight's outline, in px.
36const TARGET_OUTLINE: f32 = 2.0;
37
38/// A widget that can be picked up, as the shell sees it.
39#[derive(Copy, Clone, Debug, PartialEq)]
40pub struct DragSource {
41    /// The widget's identity.
42    pub id: WidgetId,
43    /// Where it sits, in absolute coordinates.
44    pub rect: Rect,
45    /// What it carries.
46    pub payload: u64,
47}
48
49/// A widget that can be dropped onto, as the shell sees it.
50#[derive(Copy, Clone, Debug, PartialEq)]
51pub struct DropZone {
52    /// The widget's identity.
53    pub id: WidgetId,
54    /// Where it sits, in absolute coordinates.
55    pub rect: Rect,
56}
57
58/// A widget that can be **picked up** and dropped on a [`DragTarget`].
59///
60/// It carries a `payload`: a number the application chooses and gets back on the
61/// drop, so the two ends of the gesture never have to know each other's types. What
62/// floats under the pointer is the widget's own appearance, lifted out of the frame
63/// — nothing to build twice, and nothing that can drift from what is on screen.
64pub struct Draggable<Msg = crate::callback::Callback> {
65    payload: u64,
66    long_press: bool,
67    ghost_opacity: f32,
68    on_dropped: Option<Box<dyn Fn(bool) -> Msg>>,
69    children: Vec<Box<dyn Widget<Msg>>>,
70}
71
72impl<Msg> Draggable<Msg> {
73    /// Makes `child` draggable, carrying `0` until [`Draggable::payload`] says
74    /// otherwise.
75    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
76        Self {
77            payload: 0,
78            long_press: false,
79            ghost_opacity: GHOSTED_OPACITY,
80            on_dropped: None,
81            children: vec![Box::new(child)],
82        }
83    }
84
85    /// What this item carries — an id, an index, anything the application can map
86    /// back. It is handed to the target's `on_drop` untouched.
87    pub fn payload(mut self, payload: u64) -> Self {
88        self.payload = payload;
89        self
90    }
91
92    /// Lift on a **long press** rather than on the first movement.
93    ///
94    /// This is what a draggable inside a list needs. Both gestures start with a finger
95    /// going down on the same row, and only one of them can win; resolving it by "who
96    /// is on top" would hand every drag to the item and leave the list unscrollable.
97    /// A hold is the one signal the scroll cannot claim — a finger that stays still is
98    /// not scrolling — so it is the hold that lifts.
99    pub fn long_press(mut self) -> Self {
100        self.long_press = true;
101        self
102    }
103
104    /// How solid the item left behind looks while its copy is being dragged. `1.0`
105    /// leaves it untouched, `0.0` makes the lift look like a removal.
106    pub fn ghost_opacity(mut self, opacity: f32) -> Self {
107        self.ghost_opacity = opacity.clamp(0.0, 1.0);
108        self
109    }
110
111    /// The message sent when the drag ends, `true` if a target took it.
112    ///
113    /// A refused drop is worth knowing about: it is the moment to say why, and the
114    /// only difference between "nothing happened" and "that is not allowed here".
115    pub fn on_dropped<R: crate::callback::IntoMsg<Msg>>(
116        mut self,
117        message: impl Fn(bool) -> R + 'static,
118    ) -> Self {
119        self.on_dropped = Some(Box::new(crate::callback::handler1(message)));
120        self
121    }
122}
123
124impl<Msg: Clone> Widget<Msg> for Draggable<Msg> {
125    fn style(&self) -> Style {
126        sizing_of(
127            self.children
128                .first()
129                .map(|child| child.style())
130                .unwrap_or_default(),
131        )
132    }
133
134    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
135        &self.children
136    }
137
138    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
139        // Nothing of its own: the child is the item.
140    }
141
142    fn on_click(&self) -> Option<Msg> {
143        None
144    }
145
146    // The **structural** questions, forwarded from the child. A wrapper that answered
147    // them for itself would change how its content is laid out: a `Dismissible` is a
148    // layout leaf, so wrapping it in an ordinary container leaves it with no content
149    // size and it collapses to nothing on the wrapper's main axis — silently, since
150    // neither draws a box of its own. Found on a device: a task row wrapped for
151    // dragging vanished while its checkbox still counted. The same lesson as `Keyed`,
152    // which had it the other way round.
153    fn stack(&self) -> bool {
154        self.children.first().is_some_and(|child| child.stack())
155    }
156
157    fn continuous(&self) -> bool {
158        self.children
159            .first()
160            .is_some_and(|child| child.continuous())
161    }
162
163    fn drag_payload(&self) -> Option<u64> {
164        Some(self.payload)
165    }
166
167    fn drag_needs_long_press(&self) -> bool {
168        self.long_press
169    }
170
171    fn drag_ghost_opacity(&self) -> f32 {
172        self.ghost_opacity
173    }
174
175    fn on_dropped(&self, accepted: bool) -> Option<Msg> {
176        self.on_dropped.as_ref().map(|make| make(accepted))
177    }
178}
179
180/// A widget that can be **dropped onto**.
181///
182/// By default it takes anything. `accepts` narrows that — a column that only takes
183/// tasks of its own project, say — and a target that refuses is not highlighted and
184/// not offered the drop, so the answer is visible before the finger lifts rather
185/// than after.
186pub struct DragTarget<Msg = crate::callback::Callback> {
187    accepts: Option<Box<dyn Fn(u64) -> bool>>,
188    on_drop: Option<Box<dyn Fn(u64) -> Msg>>,
189    highlight: Option<Color>,
190    children: Vec<Box<dyn Widget<Msg>>>,
191}
192
193impl<Msg> DragTarget<Msg> {
194    /// Makes `child` a drop target.
195    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
196        Self {
197            accepts: None,
198            on_drop: None,
199            highlight: None,
200            children: vec![Box::new(child)],
201        }
202    }
203
204    /// The message sent when an accepted item is dropped here, given its payload.
205    pub fn on_drop<R: crate::callback::IntoMsg<Msg>>(
206        mut self,
207        message: impl Fn(u64) -> R + 'static,
208    ) -> Self {
209        self.on_drop = Some(Box::new(crate::callback::handler1(message)));
210        self
211    }
212
213    /// Which payloads this target takes; everything, by default.
214    pub fn accepts(mut self, predicate: impl Fn(u64) -> bool + 'static) -> Self {
215        self.accepts = Some(Box::new(predicate));
216        self
217    }
218
219    /// The colour of the "drop it here" highlight. Unset, it is the theme's accent;
220    /// set it to [`Color::TRANSPARENT`] to draw none and paint your own from the
221    /// child.
222    pub fn highlight(mut self, color: Color) -> Self {
223        self.highlight = Some(color);
224        self
225    }
226}
227
228impl<Msg: Clone> Widget<Msg> for DragTarget<Msg> {
229    fn style(&self) -> Style {
230        sizing_of(
231            self.children
232                .first()
233                .map(|child| child.style())
234                .unwrap_or_default(),
235        )
236    }
237
238    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
239        &self.children
240    }
241
242    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
243        // Painted **under** the child, since `paint` runs before the children are
244        // walked: a wash over the target's own background rather than over its text.
245        if !status.drag_over {
246            return;
247        }
248        let color = self.highlight.unwrap_or(theme.primary);
249        if color.a <= 0.0 {
250            return;
251        }
252        scene.draw_rect(
253            bounds,
254            color.fade(TARGET_ALPHA),
255            theme.radius,
256            TARGET_OUTLINE,
257            color,
258        );
259    }
260
261    fn on_click(&self) -> Option<Msg> {
262        None
263    }
264
265    // The **structural** questions, forwarded from the child. A wrapper that answered
266    // them for itself would change how its content is laid out: a `Dismissible` is a
267    // layout leaf, so wrapping it in an ordinary container leaves it with no content
268    // size and it collapses to nothing on the wrapper's main axis — silently, since
269    // neither draws a box of its own. Found on a device: a task row wrapped for
270    // dragging vanished while its checkbox still counted. The same lesson as `Keyed`,
271    // which had it the other way round.
272    fn stack(&self) -> bool {
273        self.children.first().is_some_and(|child| child.stack())
274    }
275
276    fn continuous(&self) -> bool {
277        self.children
278            .first()
279            .is_some_and(|child| child.continuous())
280    }
281
282    fn drop_zone(&self) -> bool {
283        true
284    }
285
286    fn accepts_drag(&self, payload: u64) -> bool {
287        match &self.accepts {
288            Some(predicate) => predicate(payload),
289            None => true,
290        }
291    }
292
293    fn on_drop(&self, payload: u64) -> Option<Msg> {
294        self.on_drop.as_ref().map(|make| make(payload))
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::{build_ui, Container, Flex, Runtime};
302    use frus_core::{Point, Primitive, Size};
303
304    #[derive(Clone, Debug, PartialEq)]
305    enum Msg {
306        Dropped(u64),
307        Ended(bool),
308    }
309
310    fn tree() -> Flex<Msg> {
311        Flex::column()
312            .width(200.0)
313            .child(
314                Draggable::new(Container::new().width(100.0).height(40.0))
315                    .payload(7)
316                    .on_dropped(Msg::Ended),
317            )
318            .child(
319                DragTarget::new(Container::new().width(200.0).height(60.0))
320                    .on_drop(Msg::Dropped)
321                    .accepts(|payload| payload % 2 == 1),
322            )
323    }
324
325    fn ui(runtime: &Runtime) -> crate::Ui<Msg> {
326        build_ui(&tree(), Size::new(200.0, 200.0), runtime, &Theme::dark())
327    }
328
329    #[test]
330    fn a_draggable_registers_what_it_carries_and_where_it_is() {
331        let runtime = Runtime::default();
332        let ui = ui(&runtime);
333        let source = ui
334            .drag_source_at(Point::new(50.0, 20.0))
335            .expect("a source under the pointer");
336        assert_eq!(source.payload, 7);
337        assert!((source.rect.height - 40.0).abs() < 0.5);
338        // Below the item there is no source.
339        assert!(ui.drag_source_at(Point::new(50.0, 70.0)).is_none());
340    }
341
342    #[test]
343    fn a_draggable_says_how_it_wants_to_be_lifted() {
344        let plain = Draggable::<Msg>::new(Container::new());
345        assert!(!Widget::<Msg>::drag_needs_long_press(&plain));
346        let held = Draggable::<Msg>::new(Container::new()).long_press();
347        assert!(Widget::<Msg>::drag_needs_long_press(&held));
348    }
349
350    #[test]
351    fn a_draggable_row_inside_a_column_still_paints() {
352        use crate::{keyed, Dismissible, Flex};
353        let red = Color::rgb(1.0, 0.0, 0.0);
354        let row = Dismissible::<Msg>::new(Container::new().color(red).height(62.0))
355            .height(62.0)
356            .on_dismiss(Msg::Dropped(1));
357        let root = Flex::<Msg>::column()
358            .width(300.0)
359            .child(keyed(1u64, Draggable::new(row).payload(1).long_press()));
360        let runtime = Runtime::default();
361        let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
362        let painted: Vec<Rect> = ui
363            .scene()
364            .primitives()
365            .iter()
366            .filter_map(|p| match p {
367                Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
368                    Some(*rect)
369                }
370                _ => None,
371            })
372            .collect();
373        assert!(
374            !painted.is_empty(),
375            "the row vanished ({} primitives in all)",
376            ui.scene().primitives().len()
377        );
378        assert!((painted[0].height - 62.0).abs() < 1.0, "{:?}", painted[0]);
379    }
380
381    /// A target wrapping ordinary content keeps that content's height — it must not
382    /// become a layout leaf just because some other child would need it to be.
383    #[test]
384    fn a_target_around_ordinary_content_is_as_tall_as_that_content() {
385        use crate::Flex;
386        let red = Color::rgb(1.0, 0.0, 0.0);
387        let root = Flex::<Msg>::column().width(300.0).child(DragTarget::new(
388            Container::new()
389                .padding(12.0)
390                .color(red)
391                .child(Container::new().height(20.0)),
392        ));
393        let runtime = Runtime::default();
394        let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
395        let painted = ui
396            .scene()
397            .primitives()
398            .iter()
399            .find_map(|p| match p {
400                Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
401                    Some(*rect)
402                }
403                _ => None,
404            })
405            .expect("the target's content");
406        assert!(
407            (painted.height - 44.0).abs() < 1.0,
408            "20 px of content plus 12 either side: {painted:?}"
409        );
410    }
411
412    #[test]
413    fn a_target_is_found_by_where_it_is() {
414        let runtime = Runtime::default();
415        let ui = ui(&runtime);
416        assert!(ui.drop_zone_at(Point::new(100.0, 60.0)).is_some());
417        assert!(ui.drop_zone_at(Point::new(100.0, 10.0)).is_none());
418    }
419
420    #[test]
421    fn a_target_refuses_what_it_says_it_refuses() {
422        let target = DragTarget::<Msg>::new(Container::new()).accepts(|payload| payload % 2 == 1);
423        assert!(Widget::<Msg>::accepts_drag(&target, 7));
424        assert!(!Widget::<Msg>::accepts_drag(&target, 8));
425        // With no predicate, everything is welcome.
426        let open = DragTarget::<Msg>::new(Container::new());
427        assert!(Widget::<Msg>::accepts_drag(&open, 8));
428    }
429
430    #[test]
431    fn the_payload_reaches_the_drop_untouched() {
432        // Spelled through the trait: the builder of the same name would win otherwise.
433        let target = DragTarget::new(Container::new()).on_drop(Msg::Dropped);
434        assert_eq!(Widget::<Msg>::on_drop(&target, 7), Some(Msg::Dropped(7)));
435        // A target with no handler is still a target; it just has nothing to say.
436        let silent = DragTarget::<Msg>::new(Container::new());
437        assert_eq!(Widget::<Msg>::on_drop(&silent, 7), None);
438    }
439
440    #[test]
441    fn a_refused_drop_is_reported_as_such() {
442        let source = Draggable::new(Container::new()).on_dropped(Msg::Ended);
443        assert_eq!(
444            Widget::<Msg>::on_dropped(&source, true),
445            Some(Msg::Ended(true))
446        );
447        assert_eq!(
448            Widget::<Msg>::on_dropped(&source, false),
449            Some(Msg::Ended(false))
450        );
451    }
452
453    #[test]
454    fn a_target_is_only_highlighted_while_a_drag_is_over_it() {
455        let quiet = Runtime::default();
456        let plain = ui(&quiet).scene().primitives().len();
457
458        let mut hovered = Runtime::default();
459        let zone = ui(&quiet)
460            .drop_zone_at(Point::new(100.0, 60.0))
461            .expect("the target");
462        hovered.drag_over = Some(zone.id);
463        let lit = ui(&hovered);
464        assert_eq!(
465            lit.scene().primitives().len(),
466            plain + 1,
467            "exactly one highlight, and only when hovered"
468        );
469        // And it covers the target, rather than being a stray mark somewhere.
470        let highlight = lit
471            .scene()
472            .primitives()
473            .iter()
474            .filter_map(|p| match p {
475                Primitive::Rect { rect, .. } if (rect.height - 60.0).abs() < 0.5 => Some(*rect),
476                _ => None,
477            })
478            .next()
479            .expect("the highlight");
480        assert!((highlight.width - 200.0).abs() < 0.5, "{highlight:?}");
481    }
482}