1use std::rc::Rc;
171
172use frust_core::accesskit::{Action, Role};
173use frust_core::event::{FileDropEvent, FileDropPhase};
174use frust_core::{
175 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
176 LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
177};
178use frust_theme::Theme;
179use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
180use peniko::{Brush, Color};
181
182use super::coordinator::{DragCoordinator, DragPhase, DragState, DragTargetId};
183use crate::authoring::{ErasedArgCallback, ErasedCallback, TypedArgCallback, presses};
184
185const BORDER_TOLERANCE: f64 = 0.1;
188
189const HIGHLIGHT_STROKE_WIDTH: f64 = 2.0;
191
192const HIGHLIGHT_RADIUS: f64 = 8.0;
196
197pub const HIGHLIGHT_FALLBACK: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
201
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub enum DragHighlight {
206 Hover,
209 Accept,
212 Reject,
218}
219
220type HighlightPaint = Rc<dyn Fn(&mut dyn PaintScene, Size, DragHighlight)>;
223
224type ChangeCallback<State> = Rc<dyn Fn(&mut State)>;
228
229pub struct DragTargetView<State: 'static, T: 'static> {
231 child: AnyView<State>,
232 coordinator: DragCoordinator,
233 accepts: Rc<dyn Fn(&T) -> bool>,
234 on_enter: Option<ChangeCallback<State>>,
235 on_leave: Option<ChangeCallback<State>>,
236 on_hover: Option<TypedArgCallback<State, Point>>,
237 on_drop: Option<TypedArgCallback<State, T>>,
238 highlight: Option<HighlightPaint>,
239}
240
241pub fn drag_target<T, State, V>(child: V, coordinator: DragCoordinator) -> DragTargetView<State, T>
246where
247 T: 'static,
248 State: 'static,
249 V: View<State>,
250{
251 DragTargetView {
252 child: any(child),
253 coordinator,
254 accepts: Rc::new(|_: &T| true),
255 on_enter: None,
256 on_leave: None,
257 on_hover: None,
258 on_drop: None,
259 highlight: None,
260 }
261}
262
263impl<State: 'static, T: 'static> DragTargetView<State, T> {
264 pub fn accepts<F: Fn(&T) -> bool + 'static>(mut self, f: F) -> Self {
268 self.accepts = Rc::new(f);
269 self
270 }
271
272 pub fn on_enter<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
275 self.on_enter = Some(Rc::new(f));
276 self
277 }
278
279 pub fn on_leave<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
282 self.on_leave = Some(Rc::new(f));
283 self
284 }
285
286 pub fn on_hover<F: Fn(&mut State, Point) + 'static>(mut self, f: F) -> Self {
290 self.on_hover = Some(Rc::new(f));
291 self
292 }
293
294 pub fn on_drop<F: Fn(&mut State, T) + 'static>(mut self, f: F) -> Self {
297 self.on_drop = Some(Rc::new(f));
298 self
299 }
300
301 pub fn highlight<F: Fn(&mut dyn PaintScene, Size, DragHighlight) + 'static>(
304 mut self,
305 f: F,
306 ) -> Self {
307 self.highlight = Some(Rc::new(f));
308 self
309 }
310}
311
312pub struct DragTargetWidget<State: 'static, T: 'static> {
314 child: ChildPod,
315 coordinator: DragCoordinator,
316 id: DragTargetId,
317 accepts: Rc<dyn Fn(&T) -> bool>,
318 on_enter: Option<ErasedCallback>,
319 on_leave: Option<ErasedCallback>,
320 on_hover: Option<ErasedArgCallback<Point>>,
321 on_drop: Option<ErasedArgCallback<T>>,
322 highlight: Option<HighlightPaint>,
323 window_origin: Point,
327 engaged: bool,
331 last_hover_pointer: Option<Point>,
334 _state: std::marker::PhantomData<State>,
339}
340
341impl<State: 'static, T: 'static> View<State> for DragTargetView<State, T> {
342 type Element = DragTargetWidget<State, T>;
343
344 fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
345 let id = self.coordinator.new_target_id();
346 self.coordinator.register_target_for::<T>(id);
347 DragTargetWidget {
348 child: crate::authoring::build_child(&self.child, ctx),
349 coordinator: self.coordinator.clone(),
350 id,
351 accepts: Rc::clone(&self.accepts),
352 on_enter: self.on_enter.as_ref().map(crate::authoring::erase_callback),
353 on_leave: self.on_leave.as_ref().map(crate::authoring::erase_callback),
354 on_hover: self
355 .on_hover
356 .as_ref()
357 .map(crate::authoring::erase_callback_arg),
358 on_drop: self
359 .on_drop
360 .as_ref()
361 .map(crate::authoring::erase_callback_arg),
362 highlight: self.highlight.clone(),
363 window_origin: Point::ZERO,
364 engaged: false,
365 last_hover_pointer: None,
366 _state: std::marker::PhantomData,
367 }
368 }
369
370 fn rebuild(
371 &self,
372 prev: &Self,
373 element: &mut Self::Element,
374 ctx: &mut BuildCtx<'_>,
375 ) -> ChangeFlags {
376 element.coordinator = self.coordinator.clone();
382 element.accepts = Rc::clone(&self.accepts);
383 element.on_enter = self.on_enter.as_ref().map(crate::authoring::erase_callback);
384 element.on_leave = self.on_leave.as_ref().map(crate::authoring::erase_callback);
385 element.on_hover = self
386 .on_hover
387 .as_ref()
388 .map(crate::authoring::erase_callback_arg);
389 element.on_drop = self
390 .on_drop
391 .as_ref()
392 .map(crate::authoring::erase_callback_arg);
393 element.highlight = self.highlight.clone();
394 crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
395 }
396
397 fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
398 element.coordinator.unregister_target(element.id);
399 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
400 }
401}
402
403impl<State: 'static, T: 'static> DragTargetWidget<State, T> {
404 fn current_highlight(&self) -> Option<DragHighlight> {
407 match self.coordinator.state() {
408 DragState::Dragging(session) if session.hovered == Some(self.id) => {
409 if self.payload_accepted() {
410 Some(DragHighlight::Hover)
411 } else if self.coordinator.payload_is::<T>() {
412 Some(DragHighlight::Reject)
413 } else {
414 None
415 }
416 }
417 DragState::Dropping { target, .. } if target == self.id => {
418 if self.payload_accepted() {
419 Some(DragHighlight::Accept)
420 } else if self.coordinator.payload_is::<T>() {
421 Some(DragHighlight::Reject)
422 } else {
423 None
424 }
425 }
426 _ => None,
427 }
428 }
429
430 fn payload_accepted(&self) -> bool {
433 if !self.coordinator.payload_is::<T>() {
434 return false;
435 }
436 let accepts = Rc::clone(&self.accepts);
437 self.coordinator
438 .with_payload::<T, _>(move |value| accepts(value))
439 .unwrap_or(false)
440 }
441
442 fn to_local(&self, window: Point) -> Point {
444 window - self.window_origin.to_vec2()
445 }
446
447 fn to_window(&self, local: Point) -> Point {
455 local + self.window_origin.to_vec2()
456 }
457
458 fn accepts_click_drop(&self) -> bool {
473 matches!(self.coordinator.state(), DragState::Dragging(session) if session.keyboard)
474 && self.payload_accepted()
475 }
476
477 fn poll(&mut self, ctx: &mut EventCtx<'_>) {
481 match self.coordinator.state() {
482 DragState::Dragging(session) => {
483 let matches =
484 session.hovered == Some(self.id) && self.coordinator.payload_is::<T>();
485 if matches && !self.engaged {
486 self.engaged = true;
487 if let Some(cb) = self.on_enter.as_mut() {
488 cb(ctx);
489 }
490 } else if !matches && self.engaged {
491 self.engaged = false;
492 self.last_hover_pointer = None;
493 if let Some(cb) = self.on_leave.as_mut() {
494 cb(ctx);
495 }
496 }
497 if matches {
498 let pointer = session.pointer;
499 if self.last_hover_pointer != Some(pointer) {
500 self.last_hover_pointer = Some(pointer);
501 let local = self.to_local(pointer);
502 if let Some(cb) = self.on_hover.as_mut() {
503 cb(ctx, local);
504 }
505 }
506 }
507 }
508 DragState::Dropping { target, .. } if target == self.id => {
509 let was_engaged = std::mem::replace(&mut self.engaged, false);
510 self.last_hover_pointer = None;
511 if self.payload_accepted() {
512 if let Some(payload) = self.coordinator.take_payload::<T>() {
513 if let Some(cb) = self.on_drop.as_mut() {
514 cb(ctx, *payload);
515 }
516 self.coordinator.complete_drop();
517 } else {
518 self.coordinator.cancel();
521 }
522 } else {
523 self.coordinator.cancel();
524 if was_engaged && let Some(cb) = self.on_leave.as_mut() {
525 cb(ctx);
526 }
527 }
528 }
529 _ => {
530 if self.engaged {
531 self.engaged = false;
532 self.last_hover_pointer = None;
533 if let Some(cb) = self.on_leave.as_mut() {
534 cb(ctx);
535 }
536 }
537 }
538 }
539 }
540
541 fn handle_file_drop(&mut self, ctx: &mut EventCtx, drop: &FileDropEvent) -> EventResult {
565 let window_pos = self.to_window(drop.position);
566 if drop.phase == FileDropPhase::Hover && self.coordinator.phase() == DragPhase::Idle {
567 self.coordinator.begin_external(Vec::new(), window_pos);
568 }
569 if !self.coordinator.is_external() {
570 return EventResult::Ignored;
571 }
572 match drop.phase {
573 FileDropPhase::Hover => {
574 self.coordinator.update_pointer(window_pos);
575 }
576 FileDropPhase::Drop => {
577 self.coordinator.set_external_payload(drop.paths.clone());
578 self.coordinator.update_pointer(window_pos);
579 self.coordinator.drop();
580 }
581 FileDropPhase::Cancel => {
582 self.coordinator.cancel();
583 }
584 FileDropPhase::Ended => {
585 self.end_os_drag(ctx);
586 }
587 }
588 self.poll(ctx);
589 ctx.request_redraw();
590 EventResult::Handled
591 }
592
593 fn end_os_drag(&mut self, ctx: &mut EventCtx) {
602 if self.coordinator.end_external() {
603 ctx.request_redraw();
604 }
605 }
606
607 fn paint_highlight(
610 &self,
611 ctx: &mut PaintCtx,
612 scene: &mut dyn PaintScene,
613 state: DragHighlight,
614 ) {
615 let origin = ctx.origin();
616 let size = ctx.size();
617 let theme = Theme::from_paint_ctx(ctx);
618 scene.push_transform(Affine::translate(origin.to_vec2()));
619 scene.push_clip(Point::ZERO, size);
620 match &self.highlight {
621 Some(custom) => custom(scene, size, state),
622 None => default_highlight(scene, size, state, theme),
623 }
624 scene.pop_clip();
625 scene.pop_transform();
626 }
627}
628
629fn visible_bounds(ctx: &PaintCtx) -> Option<Rect> {
633 let bounds = Rect::from_origin_size(ctx.origin(), ctx.size());
634 let visible = match ctx.visible_rect() {
635 Some(clip) => bounds.intersect(clip),
636 None => bounds,
637 };
638 (visible.width() > 0.0 && visible.height() > 0.0).then_some(visible)
639}
640
641fn default_highlight(
645 scene: &mut dyn PaintScene,
646 size: Size,
647 state: DragHighlight,
648 theme: Option<&Theme>,
649) {
650 if matches!(state, DragHighlight::Reject) {
651 return;
652 }
653 let color = match theme {
654 Some(theme) => theme.scheme().primary,
655 None => HIGHLIGHT_FALLBACK,
656 };
657 let half = HIGHLIGHT_STROKE_WIDTH / 2.0;
658 let rr = RoundedRect::new(
659 half,
660 half,
661 size.width - half,
662 size.height - half,
663 (HIGHLIGHT_RADIUS - half).max(0.0),
664 );
665 let path = rr.to_path(BORDER_TOLERANCE);
666 scene.stroke_path(
667 Point::ZERO,
668 &path,
669 HIGHLIGHT_STROKE_WIDTH,
670 &Brush::Solid(color),
671 );
672}
673
674impl<State: 'static, T: 'static> Widget for DragTargetWidget<State, T> {
675 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
676 let size = self.child.layout_child(ctx, bc);
677 self.child.set_origin(Point::ZERO);
678 bc.constrain(size)
679 }
680
681 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
682 self.window_origin = ctx.origin();
683 self.coordinator
684 .report_target_bounds(self.id, visible_bounds(ctx), ctx.frame_time());
685 self.child.paint_child(ctx, scene);
686 if let Some(state) = self.current_highlight() {
687 self.paint_highlight(ctx, scene, state);
688 }
689 }
690
691 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
692 if event.is_broadcast() {
693 if let InputEvent::FileDrop(drop) = event
694 && drop.phase == FileDropPhase::Ended
695 {
696 self.end_os_drag(ctx);
697 }
698 self.poll(ctx);
699 } else if let InputEvent::FileDrop(drop) = event {
700 return self.handle_file_drop(ctx, drop);
701 }
702 if let InputEvent::Pointer(p) = event
703 && p.phase == PointerPhase::Up
704 && presses(p)
705 && self.accepts_click_drop()
706 {
707 self.coordinator.set_hovered(Some(self.id));
708 self.coordinator.drop();
709 self.poll(ctx);
715 ctx.request_redraw();
716 return EventResult::Handled;
717 }
718 crate::authoring::route_event_single(&mut self.child, ctx, event)
719 }
720
721 fn semantics(&self, ctx: &mut SemanticsCtx) {
722 let accepting = self.accepts_click_drop();
727 ctx.push_container(
728 Role::Group,
729 |node| {
730 node.set_label(if accepting {
731 "accepts drop"
732 } else {
733 "drop target"
734 });
735 if accepting {
736 node.add_action(Action::Click);
737 }
738 },
739 |ctx| self.child.semantics_child(ctx),
740 );
741 }
742
743 crate::authoring::visit_children!(child);
744}
745
746#[cfg(test)]
747mod tests {
748 use super::super::coordinator::DragPhase;
749 use super::*;
750 use crate::SizedBox;
751 use frust_core::BuildCtx;
752 use std::any::Any;
753 use std::cell::RefCell;
754
755 #[derive(Default)]
756 struct App;
757
758 fn build<S: 'static, T: 'static>(view: &DragTargetView<S, T>) -> DragTargetWidget<S, T> {
759 let mut counter = 0u64;
760 view.build(&mut BuildCtx::new(&mut counter))
761 }
762
763 fn housekeeping<S: 'static, T: 'static>(
764 w: &mut DragTargetWidget<S, T>,
765 state: &mut S,
766 ) -> EventResult {
767 let state_any: &mut dyn Any = state;
768 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
769 w.event(&mut ctx, &InputEvent::Housekeeping)
770 }
771
772 fn click<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
775 let state_any: &mut dyn Any = state;
776 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
777 let event = InputEvent::Pointer(frust_core::PointerEvent {
778 phase: PointerPhase::Up,
779 position: Point::ZERO,
780 button: frust_core::PointerButton::Primary,
781 });
782 w.event(&mut ctx, &event)
783 }
784
785 fn file_drop<S: 'static, T: 'static>(
788 w: &mut DragTargetWidget<S, T>,
789 state: &mut S,
790 phase: FileDropPhase,
791 position: Point,
792 paths: Vec<std::path::PathBuf>,
793 ) -> EventResult {
794 let state_any: &mut dyn Any = state;
795 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
796 let event = InputEvent::FileDrop(FileDropEvent {
797 phase,
798 position,
799 paths,
800 });
801 w.event(&mut ctx, &event)
802 }
803
804 fn laid_out<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, at: Point, size: Size) {
805 let mut lctx = LayoutCtx::new();
806 w.layout(&mut lctx, &BoxConstraints::tight(size));
807 let mut scene = crate::test_support::RecordingScene::default();
808 let mut pctx = PaintCtx::new(at, size);
809 w.paint(&mut pctx, &mut scene);
810 }
811
812 #[test]
813 fn build_registers_and_teardown_unregisters() {
814 let coordinator = DragCoordinator::new();
815 let view: DragTargetView<App, u32> =
816 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
817 let mut counter = 0u64;
818 let mut ctx = BuildCtx::new(&mut counter);
819 let mut w = view.build(&mut ctx);
820 assert!(coordinator.registered_targets().contains(&w.id));
821
822 view.teardown(&mut w, &mut BuildCtx::new(&mut counter));
823 assert!(!coordinator.registered_targets().contains(&w.id));
824 }
825
826 #[test]
827 fn enter_hover_leave_fire_for_a_matching_payload() {
828 let coordinator = DragCoordinator::new();
829 let entered = Rc::new(RefCell::new(0u32));
830 let left = Rc::new(RefCell::new(0u32));
831 let hovered = Rc::new(RefCell::new(Vec::new()));
832 let e2 = Rc::clone(&entered);
833 let l2 = Rc::clone(&left);
834 let h2 = Rc::clone(&hovered);
835 let view: DragTargetView<App, u32> =
836 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
837 .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
838 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
839 .on_hover(move |_s: &mut App, p: Point| h2.borrow_mut().push(p));
840 let mut w = build(&view);
841 laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
842 let mut state = App;
843
844 let source = coordinator.new_source_id();
845 coordinator.arm(source, Point::new(0.0, 0.0));
846 coordinator.begin(42u32);
847 coordinator.set_hovered(Some(w.id));
848 coordinator.update_pointer(Point::new(8.0, 9.0));
849
850 housekeeping(&mut w, &mut state);
851 assert_eq!(
852 *entered.borrow(),
853 1,
854 "on_enter fires once for the matching type"
855 );
856 assert_eq!(
857 hovered.borrow().last(),
858 Some(&Point::new(3.0, 4.0)),
859 "on_hover converts the window-space pointer to local space"
860 );
861
862 coordinator.set_hovered(None);
863 housekeeping(&mut w, &mut state);
864 assert_eq!(*left.borrow(), 1, "on_leave fires once hover ends");
865 }
866
867 #[test]
868 fn mismatched_payload_type_is_never_offered() {
869 let coordinator = DragCoordinator::new();
870 let entered = Rc::new(RefCell::new(0u32));
871 let e2 = Rc::clone(&entered);
872 let view: DragTargetView<App, u32> =
873 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
874 .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1);
875 let mut w = build(&view);
876 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
877 let mut state = App;
878
879 let source = coordinator.new_source_id();
880 coordinator.arm(source, Point::ZERO);
881 coordinator.begin("not-a-u32");
883 coordinator.set_hovered(Some(w.id));
884
885 housekeeping(&mut w, &mut state);
886 assert_eq!(
887 *entered.borrow(),
888 0,
889 "a mismatched payload type must never fire on_enter"
890 );
891 assert_eq!(
892 w.current_highlight(),
893 None,
894 "a mismatched payload type must never highlight either"
895 );
896 }
897
898 #[test]
899 fn accepts_false_rejects_the_drop() {
900 let coordinator = DragCoordinator::new();
901 let dropped = Rc::new(RefCell::new(Vec::new()));
902 let d2 = Rc::clone(&dropped);
903 let view: DragTargetView<App, u32> =
904 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
905 .accepts(|v: &u32| *v < 10)
906 .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
907 let mut w = build(&view);
908 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
909 let mut state = App;
910
911 let source = coordinator.new_source_id();
912 coordinator.arm(source, Point::ZERO);
913 coordinator.begin(99u32);
914 coordinator.set_hovered(Some(w.id));
915 assert_eq!(w.current_highlight(), Some(DragHighlight::Reject));
916
917 coordinator.drop();
918 housekeeping(&mut w, &mut state);
919 assert!(
920 dropped.borrow().is_empty(),
921 "a rejected drop must not fire on_drop"
922 );
923 assert_eq!(coordinator.phase(), DragPhase::Idle);
924 }
925
926 #[test]
927 fn accepts_true_completes_the_drop() {
928 let coordinator = DragCoordinator::new();
929 let dropped = Rc::new(RefCell::new(Vec::new()));
930 let d2 = Rc::clone(&dropped);
931 let view: DragTargetView<App, u32> =
932 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
933 .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
934 let mut w = build(&view);
935 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
936 let mut state = App;
937
938 let source = coordinator.new_source_id();
939 coordinator.arm(source, Point::ZERO);
940 coordinator.begin(7u32);
941 coordinator.set_hovered(Some(w.id));
942 assert_eq!(w.current_highlight(), Some(DragHighlight::Hover));
943
944 coordinator.drop();
945 assert_eq!(w.current_highlight(), Some(DragHighlight::Accept));
946 housekeeping(&mut w, &mut state);
947 assert_eq!(*dropped.borrow(), vec![7]);
948 assert_eq!(coordinator.phase(), DragPhase::Idle);
949 }
950
951 #[test]
952 fn paint_reports_window_space_bounds() {
953 let coordinator = DragCoordinator::new();
954 let view: DragTargetView<App, u32> =
955 drag_target(SizedBox::<App>(Some(20.0), Some(15.0)), coordinator.clone());
956 let mut w = build(&view);
957 laid_out(&mut w, Point::new(30.0, 40.0), Size::new(20.0, 15.0));
958 assert_eq!(
959 coordinator.target_bounds(w.id),
960 Some(Rect::from_origin_size(
961 Point::new(30.0, 40.0),
962 Size::new(20.0, 15.0)
963 ))
964 );
965 }
966
967 #[test]
968 fn custom_highlight_overrides_the_default() {
969 let coordinator = DragCoordinator::new();
970 let seen = Rc::new(RefCell::new(Vec::new()));
971 let seen2 = Rc::clone(&seen);
972 let view: DragTargetView<App, u32> =
973 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
974 .accepts(|v: &u32| *v < 10)
975 .highlight(move |_scene, _size, state| seen2.borrow_mut().push(state));
976 let mut w = build(&view);
977 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
978
979 let source = coordinator.new_source_id();
980 coordinator.arm(source, Point::ZERO);
981 coordinator.begin(99u32);
982 coordinator.set_hovered(Some(w.id));
983 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
984 assert_eq!(*seen.borrow(), vec![DragHighlight::Reject]);
985 }
986
987 #[test]
988 fn external_files_session_fires_callbacks_and_completes_drop() {
989 use std::path::PathBuf;
990
991 let coordinator = DragCoordinator::new();
992 let entered = Rc::new(RefCell::new(0u32));
993 let dropped = Rc::new(RefCell::new(Vec::new()));
994 let e2 = Rc::clone(&entered);
995 let d2 = Rc::clone(&dropped);
996
997 let view: DragTargetView<App, Vec<PathBuf>> =
998 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
999 .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1000 .on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
1001
1002 let mut counter = 0u64;
1003 let mut w = view.build(&mut BuildCtx::new(&mut counter));
1004 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1005 let mut state = App;
1006
1007 let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
1008 coordinator.begin_external(paths.clone(), Point::ZERO);
1009 coordinator.set_hovered(Some(w.id));
1010
1011 housekeeping(&mut w, &mut state);
1012 assert_eq!(
1013 *entered.borrow(),
1014 1,
1015 "on_enter fires once for an external files session"
1016 );
1017 assert_eq!(
1018 w.current_highlight(),
1019 Some(DragHighlight::Hover),
1020 "external files session shows Hover highlight when hovered"
1021 );
1022
1023 coordinator.drop();
1024 assert_eq!(
1025 w.current_highlight(),
1026 Some(DragHighlight::Accept),
1027 "external files session shows Accept highlight during drop"
1028 );
1029 housekeeping(&mut w, &mut state);
1030 assert_eq!(
1031 *dropped.borrow(),
1032 paths,
1033 "on_drop receives exactly the external file paths"
1034 );
1035 assert_eq!(
1036 coordinator.phase(),
1037 DragPhase::Idle,
1038 "phase returns to Idle after drop completes"
1039 );
1040 }
1041
1042 #[test]
1043 fn a_file_drop_hover_then_drop_delivers_the_dropped_paths() {
1044 use std::path::PathBuf;
1045
1046 let coordinator = DragCoordinator::new();
1047 let entered = Rc::new(RefCell::new(0u32));
1048 let dropped = Rc::new(RefCell::new(Vec::new()));
1049 let e2 = Rc::clone(&entered);
1050 let d2 = Rc::clone(&dropped);
1051
1052 let view: DragTargetView<App, Vec<PathBuf>> =
1053 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1054 .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1055 .on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
1056
1057 let mut counter = 0u64;
1058 let mut w = view.build(&mut BuildCtx::new(&mut counter));
1059 laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
1060 let mut state = App;
1061
1062 let result = file_drop(
1064 &mut w,
1065 &mut state,
1066 FileDropPhase::Hover,
1067 Point::ZERO,
1068 vec![],
1069 );
1070 assert_eq!(result, EventResult::Handled);
1071 assert_eq!(
1072 coordinator.phase(),
1073 DragPhase::Dragging,
1074 "hovering a target opens an external session"
1075 );
1076 assert_eq!(
1077 *entered.borrow(),
1078 1,
1079 "on_enter fires on the same hover that opened the session"
1080 );
1081
1082 let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
1083 let result = file_drop(
1084 &mut w,
1085 &mut state,
1086 FileDropPhase::Drop,
1087 Point::ZERO,
1088 paths.clone(),
1089 );
1090 assert_eq!(result, EventResult::Handled);
1091 assert_eq!(
1092 *dropped.borrow(),
1093 paths,
1094 "on_drop receives the dropped paths, not the empty hover list"
1095 );
1096 assert_eq!(coordinator.phase(), DragPhase::Idle);
1097 }
1098
1099 #[test]
1100 fn a_file_drop_cancel_fires_leave_and_ends_the_session() {
1101 use std::path::PathBuf;
1102
1103 let coordinator = DragCoordinator::new();
1104 let left = Rc::new(RefCell::new(0u32));
1105 let l2 = Rc::clone(&left);
1106
1107 let view: DragTargetView<App, Vec<PathBuf>> =
1108 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1109 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1110
1111 let mut counter = 0u64;
1112 let mut w = view.build(&mut BuildCtx::new(&mut counter));
1113 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1114 let mut state = App;
1115
1116 file_drop(
1117 &mut w,
1118 &mut state,
1119 FileDropPhase::Hover,
1120 Point::ZERO,
1121 vec![],
1122 );
1123 assert_eq!(coordinator.phase(), DragPhase::Dragging);
1124
1125 file_drop(
1126 &mut w,
1127 &mut state,
1128 FileDropPhase::Cancel,
1129 Point::ZERO,
1130 vec![],
1131 );
1132 assert_eq!(
1133 *left.borrow(),
1134 1,
1135 "cancelling a hovered external session fires on_leave once"
1136 );
1137 assert_eq!(coordinator.phase(), DragPhase::Idle);
1138 }
1139
1140 #[test]
1141 fn a_click_drops_an_accepted_session_with_no_pointer_travel() {
1142 let coordinator = DragCoordinator::new();
1143 let dropped = Rc::new(RefCell::new(Vec::new()));
1144 let d2 = Rc::clone(&dropped);
1145 let view: DragTargetView<App, u32> =
1146 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1147 .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
1148 let mut w = build(&view);
1149 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1150 let mut state = App;
1151
1152 let source = coordinator.new_source_id();
1155 coordinator.lift(source);
1156 coordinator.begin(7u32);
1157
1158 let result = click(&mut w, &mut state);
1159 assert_eq!(result, EventResult::Handled);
1160 assert_eq!(*dropped.borrow(), vec![7], "the click delivered the drop");
1161 assert_eq!(coordinator.phase(), DragPhase::Idle);
1162 }
1163
1164 #[test]
1165 fn a_click_with_no_live_session_falls_through_to_the_child() {
1166 let coordinator = DragCoordinator::new();
1167 let view: DragTargetView<App, u32> =
1168 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1169 let mut w = build(&view);
1170 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1171 let mut state = App;
1172 assert_eq!(click(&mut w, &mut state), EventResult::Ignored);
1173 }
1174
1175 #[test]
1176 fn a_click_with_a_rejected_payload_does_not_drop() {
1177 let coordinator = DragCoordinator::new();
1178 let view: DragTargetView<App, u32> =
1179 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1180 .accepts(|v: &u32| *v < 10);
1181 let mut w = build(&view);
1182 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1183 let mut state = App;
1184
1185 let source = coordinator.new_source_id();
1186 coordinator.arm(source, Point::ZERO);
1187 coordinator.begin(99u32);
1188
1189 click(&mut w, &mut state);
1190 assert_eq!(
1191 coordinator.phase(),
1192 DragPhase::Dragging,
1193 "a rejected click leaves the session exactly as it was"
1194 );
1195 }
1196
1197 #[test]
1198 fn semantics_label_and_action_track_acceptance() {
1199 use frust_core::accesskit::{Action, Role};
1200
1201 let coordinator = DragCoordinator::new();
1202 let logic_coordinator = coordinator.clone();
1203 let mut logic = move |_s: &mut App| {
1204 drag_target::<u32, App, _>(
1205 SizedBox::<App>(Some(10.0), Some(10.0)),
1206 logic_coordinator.clone(),
1207 )
1208 };
1209 let mut root: frust_core::RenderRoot<App, DragTargetView<App, u32>> =
1210 frust_core::RenderRoot::new();
1211 let mut state = App;
1212 root.rebuild(&mut logic, &mut state);
1213 root.layout(Size::new(100.0, 100.0));
1214
1215 let update = root.semantics();
1216 let (_, node) = update
1217 .nodes
1218 .iter()
1219 .find(|(_, n)| n.role() == Role::Group)
1220 .expect("a Group node was pushed");
1221 assert_eq!(node.label(), Some("drop target"));
1222 assert!(
1223 !node.supports_action(Action::Click),
1224 "no session: no action"
1225 );
1226
1227 let source = coordinator.new_source_id();
1228 coordinator.lift(source);
1229 coordinator.begin(7u32);
1230 root.rebuild(&mut logic, &mut state);
1231 root.layout(Size::new(100.0, 100.0));
1232 let update = root.semantics();
1233 let (_, node) = update
1234 .nodes
1235 .iter()
1236 .find(|(_, n)| n.role() == Role::Group)
1237 .expect("a Group node was pushed");
1238 assert_eq!(node.label(), Some("accepts drop"));
1239 assert!(
1240 node.supports_action(Action::Click),
1241 "accepting: Click is offered"
1242 );
1243 }
1244
1245 fn painted_at<S: 'static, T: 'static>(
1248 w: &mut DragTargetWidget<S, T>,
1249 at: Point,
1250 size: Size,
1251 frame: frust_core::FrameTime,
1252 visible: Option<Rect>,
1253 ) {
1254 let mut lctx = LayoutCtx::new();
1255 w.layout(&mut lctx, &BoxConstraints::tight(size));
1256 let mut scene = crate::test_support::RecordingScene::default();
1257 let mut pctx = PaintCtx::for_test(at, size, frame);
1258 if let Some(visible) = visible {
1259 pctx.constrain_visible_rect(visible);
1260 }
1261 w.paint(&mut pctx, &mut scene);
1262 }
1263
1264 fn frame(n: u64) -> frust_core::FrameTime {
1265 frust_core::FrameTime::from_nanos(n * 16_000_000)
1266 }
1267
1268 #[test]
1269 fn paint_reports_bounds_clipped_to_the_visible_rect() {
1270 let coordinator = DragCoordinator::new();
1271 let view: DragTargetView<App, u32> = drag_target(
1272 SizedBox::<App>(Some(100.0), Some(100.0)),
1273 coordinator.clone(),
1274 );
1275 let mut w = build(&view);
1276 painted_at(
1277 &mut w,
1278 Point::new(0.0, 0.0),
1279 Size::new(100.0, 100.0),
1280 frame(1),
1281 Some(Rect::new(0.0, 60.0, 400.0, 400.0)),
1282 );
1283 assert_eq!(
1284 coordinator.target_bounds(w.id),
1285 Some(Rect::new(0.0, 60.0, 100.0, 100.0)),
1286 "only the part inside the ancestor's visible rect is reported"
1287 );
1288 assert_eq!(
1289 coordinator.target_at(Point::new(50.0, 30.0)),
1290 None,
1291 "the clipped-away part does not resolve"
1292 );
1293 assert_eq!(coordinator.target_at(Point::new(50.0, 80.0)), Some(w.id));
1294 }
1295
1296 #[test]
1297 fn a_target_clipped_to_nothing_reports_no_bounds_and_is_not_resolved() {
1298 let coordinator = DragCoordinator::new();
1299 let view: DragTargetView<App, u32> = drag_target(
1300 SizedBox::<App>(Some(100.0), Some(100.0)),
1301 coordinator.clone(),
1302 );
1303 let mut w = build(&view);
1304 painted_at(&mut w, Point::ZERO, Size::new(100.0, 100.0), frame(1), None);
1305 assert!(coordinator.target_bounds(w.id).is_some());
1306
1307 painted_at(
1309 &mut w,
1310 Point::ZERO,
1311 Size::new(100.0, 100.0),
1312 frame(2),
1313 Some(Rect::new(0.0, 200.0, 400.0, 400.0)),
1314 );
1315 assert_eq!(coordinator.target_bounds(w.id), None);
1316 let source = coordinator.new_source_id();
1317 coordinator.arm(source, Point::ZERO);
1318 coordinator.begin(1u32);
1319 coordinator.update_pointer(Point::new(50.0, 50.0));
1320 assert_eq!(coordinator.state().session().and_then(|s| s.hovered), None);
1321 }
1322
1323 #[test]
1324 fn a_target_that_did_not_paint_this_frame_is_not_resolved() {
1325 let coordinator = DragCoordinator::new();
1326 let first_view: DragTargetView<App, u32> = drag_target(
1327 SizedBox::<App>(Some(100.0), Some(100.0)),
1328 coordinator.clone(),
1329 );
1330 let second_view: DragTargetView<App, u32> = drag_target(
1331 SizedBox::<App>(Some(100.0), Some(100.0)),
1332 coordinator.clone(),
1333 );
1334 let mut first = build(&first_view);
1335 let mut second = build(&second_view);
1336 let size = Size::new(100.0, 100.0);
1337 painted_at(&mut first, Point::ZERO, size, frame(1), None);
1338 painted_at(&mut second, Point::new(200.0, 0.0), size, frame(1), None);
1339 assert_eq!(
1340 coordinator.target_at(Point::new(50.0, 50.0)),
1341 Some(first.id)
1342 );
1343
1344 painted_at(&mut second, Point::new(200.0, 0.0), size, frame(2), None);
1346 assert_eq!(coordinator.target_at(Point::new(50.0, 50.0)), None);
1347 assert_eq!(
1348 coordinator.target_at(Point::new(250.0, 50.0)),
1349 Some(second.id)
1350 );
1351 }
1352
1353 #[test]
1354 fn overlapping_targets_of_different_types_resolve_by_the_payload_type() {
1355 let coordinator = DragCoordinator::new();
1356 let numbers: DragTargetView<App, u32> = drag_target(
1357 SizedBox::<App>(Some(100.0), Some(100.0)),
1358 coordinator.clone(),
1359 );
1360 let words: DragTargetView<App, String> = drag_target(
1361 SizedBox::<App>(Some(100.0), Some(100.0)),
1362 coordinator.clone(),
1363 );
1364 let mut numbers = build(&numbers);
1365 let mut words = build(&words);
1367 let size = Size::new(100.0, 100.0);
1368 painted_at(&mut numbers, Point::ZERO, size, frame(1), None);
1369 painted_at(&mut words, Point::ZERO, size, frame(1), None);
1370
1371 let source = coordinator.new_source_id();
1372 coordinator.arm(source, Point::ZERO);
1373 coordinator.begin(5u32);
1374 coordinator.update_pointer(Point::new(50.0, 50.0));
1375 assert_eq!(
1376 coordinator.state().session().and_then(|s| s.hovered),
1377 Some(numbers.id)
1378 );
1379 coordinator.cancel();
1380
1381 coordinator.lift(source);
1382 coordinator.begin(String::from("word"));
1383 coordinator.move_to_next_target();
1384 coordinator.move_to_next_target();
1385 assert_eq!(
1386 coordinator.state().session().and_then(|s| s.hovered),
1387 Some(words.id),
1388 "keyboard cycling only ever lands on the String target"
1389 );
1390 }
1391
1392 #[test]
1393 fn a_rejected_drop_on_a_never_entered_target_fires_no_leave() {
1394 let coordinator = DragCoordinator::new();
1395 let left = Rc::new(RefCell::new(0u32));
1396 let l2 = Rc::clone(&left);
1397 let view: DragTargetView<App, u32> =
1398 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1399 .accepts(|v: &u32| *v < 10)
1400 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1401 let mut w = build(&view);
1402 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1403 let mut state = App;
1404
1405 let source = coordinator.new_source_id();
1406 coordinator.arm(source, Point::ZERO);
1407 coordinator.begin(99u32);
1408 coordinator.set_hovered(Some(w.id));
1410 coordinator.drop();
1411 housekeeping(&mut w, &mut state);
1412 assert_eq!(coordinator.phase(), DragPhase::Idle, "the drop is refused");
1413 assert_eq!(*left.borrow(), 0, "no on_leave without an on_enter");
1414 }
1415
1416 #[test]
1417 fn a_rejected_drop_on_an_entered_target_fires_exactly_one_leave() {
1418 let coordinator = DragCoordinator::new();
1419 let entered = Rc::new(RefCell::new(0u32));
1420 let left = Rc::new(RefCell::new(0u32));
1421 let e2 = Rc::clone(&entered);
1422 let l2 = Rc::clone(&left);
1423 let view: DragTargetView<App, u32> =
1424 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1425 .accepts(|v: &u32| *v < 10)
1426 .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1427 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1428 let mut w = build(&view);
1429 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1430 let mut state = App;
1431
1432 let source = coordinator.new_source_id();
1433 coordinator.arm(source, Point::ZERO);
1434 coordinator.begin(99u32);
1435 coordinator.set_hovered(Some(w.id));
1436 housekeeping(&mut w, &mut state);
1437 assert_eq!(*entered.borrow(), 1);
1438 coordinator.drop();
1439 housekeeping(&mut w, &mut state);
1440 housekeeping(&mut w, &mut state);
1441 assert_eq!(coordinator.phase(), DragPhase::Idle);
1442 assert_eq!(*left.borrow(), 1, "one on_leave closes the one on_enter");
1443 }
1444
1445 #[test]
1446 fn a_non_t_payload_dropped_here_shows_no_reject_and_fires_no_leave() {
1447 let coordinator = DragCoordinator::new();
1448 let left = Rc::new(RefCell::new(0u32));
1449 let l2 = Rc::clone(&left);
1450 let view: DragTargetView<App, u32> =
1451 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1452 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1453 let mut w = build(&view);
1454 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1455 let mut state = App;
1456
1457 let source = coordinator.new_source_id();
1458 coordinator.arm(source, Point::ZERO);
1459 coordinator.begin("not-a-u32");
1460 coordinator.set_hovered(Some(w.id));
1462 housekeeping(&mut w, &mut state);
1463 coordinator.drop();
1464 assert_eq!(
1465 w.current_highlight(),
1466 None,
1467 "a non-T payload never reaches the highlight seam, dropped or not"
1468 );
1469 housekeeping(&mut w, &mut state);
1470 assert_eq!(coordinator.phase(), DragPhase::Idle);
1471 assert_eq!(*left.borrow(), 0);
1472 }
1473
1474 fn ended<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
1477 let state_any: &mut dyn Any = state;
1478 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
1479 let event = InputEvent::FileDrop(FileDropEvent {
1480 phase: FileDropPhase::Ended,
1481 position: Point::ZERO,
1482 paths: Vec::new(),
1483 });
1484 w.event(&mut ctx, &event)
1485 }
1486
1487 #[test]
1488 fn the_ended_broadcast_ends_a_hovering_external_session_exactly_once() {
1489 use std::path::PathBuf;
1490
1491 let coordinator = DragCoordinator::new();
1492 let left = Rc::new(RefCell::new(0u32));
1493 let dropped = Rc::new(RefCell::new(0u32));
1494 let (l2, d2) = (Rc::clone(&left), Rc::clone(&dropped));
1495 let hovered_view: DragTargetView<App, Vec<PathBuf>> =
1496 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1497 .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
1498 .on_drop(move |_s: &mut App, _paths: Vec<PathBuf>| *d2.borrow_mut() += 1);
1499 let other_view: DragTargetView<App, Vec<PathBuf>> =
1500 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1501 let mut hovered = build(&hovered_view);
1502 let mut other = build(&other_view);
1503 laid_out(&mut hovered, Point::ZERO, Size::new(10.0, 10.0));
1504 laid_out(&mut other, Point::new(50.0, 0.0), Size::new(10.0, 10.0));
1505 let mut state = App;
1506
1507 file_drop(
1508 &mut hovered,
1509 &mut state,
1510 FileDropPhase::Hover,
1511 Point::new(5.0, 5.0),
1512 vec![],
1513 );
1514 assert_eq!(
1515 coordinator.state().session().and_then(|s| s.hovered),
1516 Some(hovered.id)
1517 );
1518
1519 assert_eq!(ended(&mut other, &mut state), EventResult::Ignored);
1522 assert_eq!(
1523 coordinator.phase(),
1524 DragPhase::Idle,
1525 "the first target ended it"
1526 );
1527 assert_eq!(ended(&mut hovered, &mut state), EventResult::Ignored);
1528 assert_eq!(coordinator.phase(), DragPhase::Idle);
1529 assert_eq!(
1530 *left.borrow(),
1531 1,
1532 "the hovered target's on_leave fired once"
1533 );
1534 assert_eq!(*dropped.borrow(), 0, "nothing accepted the drop");
1535 }
1536
1537 #[test]
1538 fn file_drop_events_leave_an_in_app_session_untouched() {
1539 let coordinator = DragCoordinator::new();
1540 let view: DragTargetView<App, Vec<std::path::PathBuf>> =
1541 drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1542 let mut w = build(&view);
1543 laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1544 let mut state = App;
1545 let source = coordinator.new_source_id();
1546
1547 let sessions: [&dyn Fn(); 3] = [
1550 &|| {
1551 coordinator.arm(source, Point::new(1.0, 1.0));
1552 },
1553 &|| {
1554 coordinator.arm(source, Point::new(1.0, 1.0));
1555 coordinator.begin(3_u32);
1556 },
1557 &|| {
1558 coordinator.lift(source);
1559 coordinator.begin(4_u32);
1560 },
1561 ];
1562 for start in sessions {
1563 start();
1564 let before = coordinator.state();
1565 for phase in [
1566 FileDropPhase::Hover,
1567 FileDropPhase::Drop,
1568 FileDropPhase::Cancel,
1569 ] {
1570 let result = file_drop(
1571 &mut w,
1572 &mut state,
1573 phase,
1574 Point::new(5.0, 5.0),
1575 vec![std::path::PathBuf::from("/tmp/a.txt")],
1576 );
1577 assert_eq!(result, EventResult::Ignored, "{phase:?} is not ours");
1578 assert_eq!(coordinator.state(), before, "{phase:?} touched nothing");
1579 }
1580 ended(&mut w, &mut state);
1581 assert_eq!(coordinator.state(), before, "nor did the Ended follow-up");
1582 coordinator.cancel();
1583 }
1584 }
1585
1586 struct FileApp {
1591 coordinator: DragCoordinator,
1592 entered: u32,
1593 left: u32,
1594 dropped: Vec<std::path::PathBuf>,
1595 }
1596
1597 fn file_app_logic(state: &mut FileApp) -> crate::StackView<FileApp> {
1598 crate::stack()
1599 .child(SizedBox::<FileApp>(Some(400.0), Some(400.0)))
1600 .child(
1601 drag_target::<Vec<std::path::PathBuf>, FileApp, _>(
1602 SizedBox::<FileApp>(Some(100.0), Some(100.0)),
1603 state.coordinator.clone(),
1604 )
1605 .on_enter(|s: &mut FileApp| s.entered += 1)
1606 .on_leave(|s: &mut FileApp| s.left += 1)
1607 .on_drop(|s: &mut FileApp, paths| s.dropped = paths),
1608 )
1609 }
1610
1611 #[test]
1612 fn an_os_drag_released_away_from_every_target_ends_its_session() {
1613 let (mut root, mut state) = file_app_root();
1614 let event = |phase, x, y, paths| {
1615 InputEvent::FileDrop(FileDropEvent {
1616 phase,
1617 position: Point::new(x, y),
1618 paths,
1619 })
1620 };
1621 for (round, end) in [FileDropPhase::Cancel, FileDropPhase::Drop]
1622 .into_iter()
1623 .enumerate()
1624 {
1625 root.event(&mut state, &event(FileDropPhase::Hover, 50.0, 50.0, vec![]));
1626 assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1627 assert_eq!(state.entered, round as u32 + 1);
1628
1629 let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
1630 let outcome = root.event(&mut state, &event(end, 300.0, 300.0, paths));
1631 assert!(!outcome.handled, "{end:?} missed the only target");
1632 assert_eq!(
1633 state.coordinator.phase(),
1634 DragPhase::Idle,
1635 "a {end:?} away from every target still ends the session"
1636 );
1637 assert_eq!(state.left, round as u32 + 1, "and the target hears Leave");
1638 assert!(state.dropped.is_empty(), "nothing accepted the paths");
1639 }
1640
1641 let source = state.coordinator.new_source_id();
1643 assert!(state.coordinator.arm(source, Point::new(10.0, 10.0)));
1644 }
1645
1646 fn file_app_root() -> (
1649 frust_core::RenderRoot<FileApp, crate::StackView<FileApp>>,
1650 FileApp,
1651 ) {
1652 let mut root: frust_core::RenderRoot<FileApp, crate::StackView<FileApp>> =
1653 frust_core::RenderRoot::new();
1654 let mut state = FileApp {
1655 coordinator: DragCoordinator::new(),
1656 entered: 0,
1657 left: 0,
1658 dropped: Vec::new(),
1659 };
1660 let mut logic: fn(&mut FileApp) -> crate::StackView<FileApp> = file_app_logic;
1661 root.rebuild(&mut logic, &mut state);
1662 root.layout(Size::new(400.0, 400.0));
1663 let mut scene = crate::test_support::RecordingScene::default();
1664 root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
1665 (root, state)
1666 }
1667
1668 fn os_drag(phase: FileDropPhase, at: Point) -> InputEvent {
1669 InputEvent::FileDrop(FileDropEvent {
1670 phase,
1671 position: at,
1672 paths: Vec::new(),
1673 })
1674 }
1675
1676 fn primary(phase: PointerPhase, at: Point) -> InputEvent {
1677 InputEvent::Pointer(frust_core::PointerEvent {
1678 phase,
1679 position: at,
1680 button: frust_core::PointerButton::Primary,
1681 })
1682 }
1683
1684 #[test]
1693 fn an_os_drag_whose_hover_ends_without_a_drop_ends_its_session() {
1694 let over = Point::new(50.0, 50.0);
1695 let away = Point::new(300.0, 300.0);
1696 for cursor in [over, away] {
1697 for with_button in [false, true] {
1698 let (mut root, mut state) = file_app_root();
1699 root.event(&mut state, &os_drag(FileDropPhase::Hover, over));
1700 root.event(&mut state, &os_drag(FileDropPhase::Hover, cursor));
1701 assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1702 assert_eq!(state.entered, 1);
1703
1704 root.event(&mut state, &os_drag(FileDropPhase::Cancel, cursor));
1707 if with_button {
1708 root.event(&mut state, &primary(PointerPhase::Down, cursor));
1709 root.event(&mut state, &primary(PointerPhase::Up, cursor));
1710 }
1711 let case = format!("cursor at {cursor:?}, button event: {with_button}");
1712 assert_eq!(state.coordinator.phase(), DragPhase::Idle, "{case}");
1713 assert_eq!(state.left, 1, "{case}: the entered target hears Leave");
1714 assert!(state.dropped.is_empty(), "{case}: nothing was dropped");
1715
1716 let source = state.coordinator.new_source_id();
1717 assert!(
1718 state.coordinator.arm(source, Point::new(10.0, 10.0)),
1719 "{case}: an in-app drag can start again"
1720 );
1721 }
1722 }
1723 }
1724
1725 #[test]
1730 fn an_os_drag_hover_left_standing_blocks_in_app_drags() {
1731 let (mut root, mut state) = file_app_root();
1732 let away = Point::new(300.0, 300.0);
1733 root.event(
1734 &mut state,
1735 &os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
1736 );
1737 root.event(&mut state, &primary(PointerPhase::Down, away));
1738 root.event(&mut state, &primary(PointerPhase::Up, away));
1739 assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1740 let source = state.coordinator.new_source_id();
1741 assert!(!state.coordinator.arm(source, Point::new(10.0, 10.0)));
1742 }
1743
1744 struct TwoGroups {
1748 a: DragCoordinator,
1749 b: DragCoordinator,
1750 a_left: u32,
1751 a_dropped: Option<Vec<std::path::PathBuf>>,
1752 b_dropped: Option<Vec<std::path::PathBuf>>,
1753 }
1754
1755 fn two_groups_logic(state: &mut TwoGroups) -> crate::StackView<TwoGroups> {
1756 crate::stack()
1757 .child(SizedBox::<TwoGroups>(Some(400.0), Some(400.0)))
1758 .child(
1759 crate::row()
1760 .child(
1761 drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
1762 SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
1763 state.a.clone(),
1764 )
1765 .on_leave(|s: &mut TwoGroups| s.a_left += 1)
1766 .on_drop(|s: &mut TwoGroups, paths| s.a_dropped = Some(paths)),
1767 )
1768 .child(
1769 drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
1770 SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
1771 state.b.clone(),
1772 )
1773 .on_drop(|s: &mut TwoGroups, paths| s.b_dropped = Some(paths)),
1774 ),
1775 )
1776 }
1777
1778 #[test]
1783 fn a_drop_handled_by_one_coordinator_still_ends_anothers_session() {
1784 let mut root: frust_core::RenderRoot<TwoGroups, crate::StackView<TwoGroups>> =
1785 frust_core::RenderRoot::new();
1786 let mut state = TwoGroups {
1787 a: DragCoordinator::new(),
1788 b: DragCoordinator::new(),
1789 a_left: 0,
1790 a_dropped: None,
1791 b_dropped: None,
1792 };
1793 let mut logic: fn(&mut TwoGroups) -> crate::StackView<TwoGroups> = two_groups_logic;
1794 root.rebuild(&mut logic, &mut state);
1795 root.layout(Size::new(400.0, 400.0));
1796 let mut scene = crate::test_support::RecordingScene::default();
1797 root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
1798
1799 root.event(
1800 &mut state,
1801 &os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
1802 );
1803 assert_eq!(state.a.phase(), DragPhase::Dragging, "a opened a session");
1804 assert_eq!(state.b.phase(), DragPhase::Idle);
1805 root.event(
1806 &mut state,
1807 &os_drag(FileDropPhase::Hover, Point::new(150.0, 50.0)),
1808 );
1809 assert_eq!(state.b.phase(), DragPhase::Dragging, "and so did b");
1810
1811 let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
1812 let outcome = root.event(
1813 &mut state,
1814 &InputEvent::FileDrop(FileDropEvent {
1815 phase: FileDropPhase::Drop,
1816 position: Point::new(150.0, 50.0),
1817 paths: paths.clone(),
1818 }),
1819 );
1820 assert!(outcome.handled, "b's target handled the Drop");
1821 assert_eq!(state.b_dropped, Some(paths), "b completed its drop");
1822 assert_eq!(state.b.phase(), DragPhase::Idle);
1823 assert_eq!(state.a.phase(), DragPhase::Idle, "a's session ended too");
1824 assert_eq!(state.a_left, 1, "with the Leave a's hovered target is owed");
1825 assert_eq!(state.a_dropped, None, "a was not dropped on");
1826
1827 for coordinator in [&state.a, &state.b] {
1828 let source = coordinator.new_source_id();
1829 assert!(coordinator.arm(source, Point::new(10.0, 10.0)));
1830 }
1831 }
1832
1833 struct Kanban {
1841 coordinator: DragCoordinator,
1842 a_entered: u32,
1843 a_dropped: Option<u32>,
1844 b_entered: u32,
1845 b_dropped: Option<u32>,
1846 }
1847
1848 fn kanban_logic(state: &mut Kanban) -> crate::StackView<Kanban> {
1849 let card = super::super::draggable::draggable(
1850 SizedBox::<Kanban>(Some(80.0), Some(40.0)),
1851 state.coordinator.clone(),
1852 |_: &Kanban| 7u32,
1853 );
1854 let a = drag_target::<u32, Kanban, _>(
1855 SizedBox::<Kanban>(Some(100.0), Some(100.0)).child(crate::column().child(card)),
1856 state.coordinator.clone(),
1857 )
1858 .on_enter(|s: &mut Kanban| s.a_entered += 1)
1859 .on_drop(|s: &mut Kanban, v: u32| s.a_dropped = Some(v));
1860 let b = drag_target::<u32, Kanban, _>(
1861 SizedBox::<Kanban>(Some(100.0), Some(100.0)),
1862 state.coordinator.clone(),
1863 )
1864 .on_enter(|s: &mut Kanban| s.b_entered += 1)
1865 .on_drop(|s: &mut Kanban, v: u32| s.b_dropped = Some(v));
1866 crate::stack()
1867 .child(SizedBox::<Kanban>(Some(400.0), Some(400.0)))
1868 .child(crate::row().child(a).child(b))
1869 }
1870
1871 struct KanbanHarness {
1877 root: frust_core::RenderRoot<Kanban, crate::StackView<Kanban>>,
1878 state: Kanban,
1879 clock_ms: f64,
1880 }
1881
1882 impl KanbanHarness {
1883 fn new() -> Self {
1884 let mut h = KanbanHarness {
1885 root: frust_core::RenderRoot::new(),
1886 state: Kanban {
1887 coordinator: DragCoordinator::new(),
1888 a_entered: 0,
1889 a_dropped: None,
1890 b_entered: 0,
1891 b_dropped: None,
1892 },
1893 clock_ms: 0.0,
1894 };
1895 h.frame();
1896 h
1897 }
1898
1899 fn frame(&mut self) {
1900 let mut build: fn(&mut Kanban) -> crate::StackView<Kanban> = kanban_logic;
1901 self.root.rebuild(&mut build, &mut self.state);
1902 self.root.layout(Size::new(400.0, 400.0));
1903 self.clock_ms += 16.0;
1904 let mut scene = crate::test_support::RecordingScene::default();
1905 self.root.paint(
1906 &mut scene,
1907 frust_core::FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
1908 );
1909 }
1910
1911 fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
1912 self.root
1913 .event(&mut self.state, &primary(phase, Point::new(x, y)));
1914 }
1915
1916 fn touch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1917 use frust_core::event::PointerId;
1918 self.root.event(
1919 &mut self.state,
1920 &InputEvent::PointerContact {
1921 pointer_id: PointerId::touch(0),
1922 event: frust_core::PointerEvent {
1923 phase,
1924 position: Point::new(x, y),
1925 button: frust_core::PointerButton::Primary,
1926 },
1927 },
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn a_mouse_drag_dropped_on_the_sibling_column_fires_only_its_on_drop() {
1934 let mut h = KanbanHarness::new();
1935 h.mouse(PointerPhase::Down, 20.0, 20.0);
1937 h.mouse(PointerPhase::Move, 30.0, 20.0);
1939 assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
1940 h.mouse(PointerPhase::Move, 150.0, 50.0);
1942 let a_entered_before_release = h.state.a_entered;
1943 h.mouse(PointerPhase::Up, 150.0, 50.0);
1944 h.frame();
1945 assert_eq!(
1946 h.state.coordinator.phase(),
1947 DragPhase::Idle,
1948 "b claimed the drop"
1949 );
1950 assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
1951 assert_eq!(
1952 h.state.a_dropped, None,
1953 "a's on_drop never fires — the card left it before release"
1954 );
1955 assert_eq!(
1956 h.state.a_entered, a_entered_before_release,
1957 "the Up must not re-enter the source column (the old click-to-drop bug)"
1958 );
1959 }
1960
1961 #[test]
1962 fn a_touch_long_press_dropped_on_the_sibling_column_fires_only_its_on_drop() {
1963 let mut h = KanbanHarness::new();
1964 h.touch(PointerPhase::Down, 20.0, 20.0);
1965 let mut frames = 0;
1966 while h.state.coordinator.phase() != DragPhase::Dragging {
1967 h.frame();
1968 frames += 1;
1969 assert!(frames < 60, "the hold never began a drag");
1970 }
1971 h.touch(PointerPhase::Move, 150.0, 50.0);
1972 h.frame();
1973 let a_entered_before_release = h.state.a_entered;
1974 h.touch(PointerPhase::Up, 150.0, 50.0);
1975 h.frame();
1976 assert_eq!(
1977 h.state.coordinator.phase(),
1978 DragPhase::Idle,
1979 "b claimed the drop"
1980 );
1981 assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
1982 assert_eq!(
1983 h.state.a_dropped, None,
1984 "a's on_drop never fires — the card left it before release"
1985 );
1986 assert_eq!(
1987 h.state.a_entered, a_entered_before_release,
1988 "the Up must not re-enter the source column (the old click-to-drop bug)"
1989 );
1990 }
1991
1992 #[test]
1993 fn a_keyboard_sessions_click_to_drop_still_drops_on_the_cycled_target() {
1994 let mut h = KanbanHarness::new();
1995 let source = h.state.coordinator.new_source_id();
1996 h.state.coordinator.lift(source);
1997 h.state.coordinator.begin(7u32);
1998 h.state.coordinator.move_to_next_target();
2000 h.state.coordinator.move_to_next_target();
2001 h.frame();
2002 assert_eq!(h.state.b_entered, 1, "cycling landed the hover on b");
2003
2004 h.mouse(PointerPhase::Up, 150.0, 50.0);
2008 assert_eq!(h.state.b_dropped, Some(7), "the click delivered the drop");
2009 assert_eq!(h.state.coordinator.phase(), DragPhase::Idle);
2010 }
2011}