1use 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
38const GHOSTED_OPACITY: f32 = 0.35;
40const TARGET_ALPHA: f32 = 0.12;
42const TARGET_OUTLINE: f32 = 2.0;
44
45#[derive(Copy, Clone, Debug, PartialEq)]
47pub struct DragSource {
48 pub id: WidgetId,
50 pub rect: Rect,
52 pub payload: u64,
54}
55
56#[derive(Copy, Clone, Debug, PartialEq)]
58pub struct DropZone {
59 pub id: WidgetId,
61 pub rect: Rect,
63}
64
65pub 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 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 pub fn payload(mut self, payload: u64) -> Self {
95 self.payload = payload;
96 self
97 }
98
99 pub fn long_press(mut self) -> Self {
107 self.long_press = true;
108 self
109 }
110
111 pub fn ghost_opacity(mut self, opacity: f32) -> Self {
114 self.ghost_opacity = opacity.clamp(0.0, 1.0);
115 self
116 }
117
118 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 }
148
149 fn on_click(&self) -> Option<Msg> {
150 None
151 }
152
153 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
187type FilesPredicate = Box<dyn Fn(&[PathBuf]) -> bool>;
189
190pub 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 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 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 pub fn accepts(mut self, predicate: impl Fn(u64) -> bool + 'static) -> Self {
238 self.accepts = Some(Box::new(predicate));
239 self
240 }
241
242 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 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 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 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 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 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 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 #[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 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 let target = DragTarget::new(Container::new()).on_drop(Msg::Dropped);
502 assert_eq!(Widget::<Msg>::on_drop(&target, 7), Some(Msg::Dropped(7)));
503 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 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 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 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 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}