use std::rc::Rc;
use frust_core::accesskit::{Action, Role};
use frust_core::event::{FileDropEvent, FileDropPhase};
use frust_core::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust_theme::Theme;
use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
use peniko::{Brush, Color};
use super::coordinator::{DragCoordinator, DragPhase, DragState, DragTargetId};
use crate::authoring::{ErasedArgCallback, ErasedCallback, TypedArgCallback, presses};
const BORDER_TOLERANCE: f64 = 0.1;
const HIGHLIGHT_STROKE_WIDTH: f64 = 2.0;
const HIGHLIGHT_RADIUS: f64 = 8.0;
pub const HIGHLIGHT_FALLBACK: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DragHighlight {
Hover,
Accept,
Reject,
}
type HighlightPaint = Rc<dyn Fn(&mut dyn PaintScene, Size, DragHighlight)>;
type ChangeCallback<State> = Rc<dyn Fn(&mut State)>;
pub struct DragTargetView<State: 'static, T: 'static> {
child: AnyView<State>,
coordinator: DragCoordinator,
accepts: Rc<dyn Fn(&T) -> bool>,
on_enter: Option<ChangeCallback<State>>,
on_leave: Option<ChangeCallback<State>>,
on_hover: Option<TypedArgCallback<State, Point>>,
on_drop: Option<TypedArgCallback<State, T>>,
highlight: Option<HighlightPaint>,
}
pub fn drag_target<T, State, V>(child: V, coordinator: DragCoordinator) -> DragTargetView<State, T>
where
T: 'static,
State: 'static,
V: View<State>,
{
DragTargetView {
child: any(child),
coordinator,
accepts: Rc::new(|_: &T| true),
on_enter: None,
on_leave: None,
on_hover: None,
on_drop: None,
highlight: None,
}
}
impl<State: 'static, T: 'static> DragTargetView<State, T> {
pub fn accepts<F: Fn(&T) -> bool + 'static>(mut self, f: F) -> Self {
self.accepts = Rc::new(f);
self
}
pub fn on_enter<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
self.on_enter = Some(Rc::new(f));
self
}
pub fn on_leave<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
self.on_leave = Some(Rc::new(f));
self
}
pub fn on_hover<F: Fn(&mut State, Point) + 'static>(mut self, f: F) -> Self {
self.on_hover = Some(Rc::new(f));
self
}
pub fn on_drop<F: Fn(&mut State, T) + 'static>(mut self, f: F) -> Self {
self.on_drop = Some(Rc::new(f));
self
}
pub fn highlight<F: Fn(&mut dyn PaintScene, Size, DragHighlight) + 'static>(
mut self,
f: F,
) -> Self {
self.highlight = Some(Rc::new(f));
self
}
}
pub struct DragTargetWidget<State: 'static, T: 'static> {
child: ChildPod,
coordinator: DragCoordinator,
id: DragTargetId,
accepts: Rc<dyn Fn(&T) -> bool>,
on_enter: Option<ErasedCallback>,
on_leave: Option<ErasedCallback>,
on_hover: Option<ErasedArgCallback<Point>>,
on_drop: Option<ErasedArgCallback<T>>,
highlight: Option<HighlightPaint>,
window_origin: Point,
engaged: bool,
last_hover_pointer: Option<Point>,
_state: std::marker::PhantomData<State>,
}
impl<State: 'static, T: 'static> View<State> for DragTargetView<State, T> {
type Element = DragTargetWidget<State, T>;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
let id = self.coordinator.new_target_id();
self.coordinator.register_target_for::<T>(id);
DragTargetWidget {
child: crate::authoring::build_child(&self.child, ctx),
coordinator: self.coordinator.clone(),
id,
accepts: Rc::clone(&self.accepts),
on_enter: self.on_enter.as_ref().map(crate::authoring::erase_callback),
on_leave: self.on_leave.as_ref().map(crate::authoring::erase_callback),
on_hover: self
.on_hover
.as_ref()
.map(crate::authoring::erase_callback_arg),
on_drop: self
.on_drop
.as_ref()
.map(crate::authoring::erase_callback_arg),
highlight: self.highlight.clone(),
window_origin: Point::ZERO,
engaged: false,
last_hover_pointer: None,
_state: std::marker::PhantomData,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.coordinator = self.coordinator.clone();
element.accepts = Rc::clone(&self.accepts);
element.on_enter = self.on_enter.as_ref().map(crate::authoring::erase_callback);
element.on_leave = self.on_leave.as_ref().map(crate::authoring::erase_callback);
element.on_hover = self
.on_hover
.as_ref()
.map(crate::authoring::erase_callback_arg);
element.on_drop = self
.on_drop
.as_ref()
.map(crate::authoring::erase_callback_arg);
element.highlight = self.highlight.clone();
crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
element.coordinator.unregister_target(element.id);
crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl<State: 'static, T: 'static> DragTargetWidget<State, T> {
fn current_highlight(&self) -> Option<DragHighlight> {
match self.coordinator.state() {
DragState::Dragging(session) if session.hovered == Some(self.id) => {
if self.payload_accepted() {
Some(DragHighlight::Hover)
} else if self.coordinator.payload_is::<T>() {
Some(DragHighlight::Reject)
} else {
None
}
}
DragState::Dropping { target, .. } if target == self.id => {
if self.payload_accepted() {
Some(DragHighlight::Accept)
} else if self.coordinator.payload_is::<T>() {
Some(DragHighlight::Reject)
} else {
None
}
}
_ => None,
}
}
fn payload_accepted(&self) -> bool {
if !self.coordinator.payload_is::<T>() {
return false;
}
let accepts = Rc::clone(&self.accepts);
self.coordinator
.with_payload::<T, _>(move |value| accepts(value))
.unwrap_or(false)
}
fn to_local(&self, window: Point) -> Point {
window - self.window_origin.to_vec2()
}
fn to_window(&self, local: Point) -> Point {
local + self.window_origin.to_vec2()
}
fn accepts_click_drop(&self) -> bool {
matches!(self.coordinator.state(), DragState::Dragging(session) if session.keyboard)
&& self.payload_accepted()
}
fn poll(&mut self, ctx: &mut EventCtx<'_>) {
match self.coordinator.state() {
DragState::Dragging(session) => {
let matches =
session.hovered == Some(self.id) && self.coordinator.payload_is::<T>();
if matches && !self.engaged {
self.engaged = true;
if let Some(cb) = self.on_enter.as_mut() {
cb(ctx);
}
} else if !matches && self.engaged {
self.engaged = false;
self.last_hover_pointer = None;
if let Some(cb) = self.on_leave.as_mut() {
cb(ctx);
}
}
if matches {
let pointer = session.pointer;
if self.last_hover_pointer != Some(pointer) {
self.last_hover_pointer = Some(pointer);
let local = self.to_local(pointer);
if let Some(cb) = self.on_hover.as_mut() {
cb(ctx, local);
}
}
}
}
DragState::Dropping { target, .. } if target == self.id => {
let was_engaged = std::mem::replace(&mut self.engaged, false);
self.last_hover_pointer = None;
if self.payload_accepted() {
if let Some(payload) = self.coordinator.take_payload::<T>() {
if let Some(cb) = self.on_drop.as_mut() {
cb(ctx, *payload);
}
self.coordinator.complete_drop();
} else {
self.coordinator.cancel();
}
} else {
self.coordinator.cancel();
if was_engaged && let Some(cb) = self.on_leave.as_mut() {
cb(ctx);
}
}
}
_ => {
if self.engaged {
self.engaged = false;
self.last_hover_pointer = None;
if let Some(cb) = self.on_leave.as_mut() {
cb(ctx);
}
}
}
}
}
fn handle_file_drop(&mut self, ctx: &mut EventCtx, drop: &FileDropEvent) -> EventResult {
let window_pos = self.to_window(drop.position);
if drop.phase == FileDropPhase::Hover && self.coordinator.phase() == DragPhase::Idle {
self.coordinator.begin_external(Vec::new(), window_pos);
}
if !self.coordinator.is_external() {
return EventResult::Ignored;
}
match drop.phase {
FileDropPhase::Hover => {
self.coordinator.update_pointer(window_pos);
}
FileDropPhase::Drop => {
self.coordinator.set_external_payload(drop.paths.clone());
self.coordinator.update_pointer(window_pos);
self.coordinator.drop();
}
FileDropPhase::Cancel => {
self.coordinator.cancel();
}
FileDropPhase::Ended => {
self.end_os_drag(ctx);
}
}
self.poll(ctx);
ctx.request_redraw();
EventResult::Handled
}
fn end_os_drag(&mut self, ctx: &mut EventCtx) {
if self.coordinator.end_external() {
ctx.request_redraw();
}
}
fn paint_highlight(
&self,
ctx: &mut PaintCtx,
scene: &mut dyn PaintScene,
state: DragHighlight,
) {
let origin = ctx.origin();
let size = ctx.size();
let theme = Theme::from_paint_ctx(ctx);
scene.push_transform(Affine::translate(origin.to_vec2()));
scene.push_clip(Point::ZERO, size);
match &self.highlight {
Some(custom) => custom(scene, size, state),
None => default_highlight(scene, size, state, theme),
}
scene.pop_clip();
scene.pop_transform();
}
}
fn visible_bounds(ctx: &PaintCtx) -> Option<Rect> {
let bounds = Rect::from_origin_size(ctx.origin(), ctx.size());
let visible = match ctx.visible_rect() {
Some(clip) => bounds.intersect(clip),
None => bounds,
};
(visible.width() > 0.0 && visible.height() > 0.0).then_some(visible)
}
fn default_highlight(
scene: &mut dyn PaintScene,
size: Size,
state: DragHighlight,
theme: Option<&Theme>,
) {
if matches!(state, DragHighlight::Reject) {
return;
}
let color = match theme {
Some(theme) => theme.scheme().primary,
None => HIGHLIGHT_FALLBACK,
};
let half = HIGHLIGHT_STROKE_WIDTH / 2.0;
let rr = RoundedRect::new(
half,
half,
size.width - half,
size.height - half,
(HIGHLIGHT_RADIUS - half).max(0.0),
);
let path = rr.to_path(BORDER_TOLERANCE);
scene.stroke_path(
Point::ZERO,
&path,
HIGHLIGHT_STROKE_WIDTH,
&Brush::Solid(color),
);
}
impl<State: 'static, T: 'static> Widget for DragTargetWidget<State, T> {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.child.layout_child(ctx, bc);
self.child.set_origin(Point::ZERO);
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.window_origin = ctx.origin();
self.coordinator
.report_target_bounds(self.id, visible_bounds(ctx), ctx.frame_time());
self.child.paint_child(ctx, scene);
if let Some(state) = self.current_highlight() {
self.paint_highlight(ctx, scene, state);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
if let InputEvent::FileDrop(drop) = event
&& drop.phase == FileDropPhase::Ended
{
self.end_os_drag(ctx);
}
self.poll(ctx);
} else if let InputEvent::FileDrop(drop) = event {
return self.handle_file_drop(ctx, drop);
}
if let InputEvent::Pointer(p) = event
&& p.phase == PointerPhase::Up
&& presses(p)
&& self.accepts_click_drop()
{
self.coordinator.set_hovered(Some(self.id));
self.coordinator.drop();
self.poll(ctx);
ctx.request_redraw();
return EventResult::Handled;
}
crate::authoring::route_event_single(&mut self.child, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let accepting = self.accepts_click_drop();
ctx.push_container(
Role::Group,
|node| {
node.set_label(if accepting {
"accepts drop"
} else {
"drop target"
});
if accepting {
node.add_action(Action::Click);
}
},
|ctx| self.child.semantics_child(ctx),
);
}
crate::authoring::visit_children!(child);
}
#[cfg(test)]
mod tests {
use super::super::coordinator::DragPhase;
use super::*;
use crate::SizedBox;
use frust_core::BuildCtx;
use std::any::Any;
use std::cell::RefCell;
#[derive(Default)]
struct App;
fn build<S: 'static, T: 'static>(view: &DragTargetView<S, T>) -> DragTargetWidget<S, T> {
let mut counter = 0u64;
view.build(&mut BuildCtx::new(&mut counter))
}
fn housekeeping<S: 'static, T: 'static>(
w: &mut DragTargetWidget<S, T>,
state: &mut S,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
w.event(&mut ctx, &InputEvent::Housekeeping)
}
fn click<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
let event = InputEvent::Pointer(frust_core::PointerEvent {
phase: PointerPhase::Up,
position: Point::ZERO,
button: frust_core::PointerButton::Primary,
});
w.event(&mut ctx, &event)
}
fn file_drop<S: 'static, T: 'static>(
w: &mut DragTargetWidget<S, T>,
state: &mut S,
phase: FileDropPhase,
position: Point,
paths: Vec<std::path::PathBuf>,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
let event = InputEvent::FileDrop(FileDropEvent {
phase,
position,
paths,
});
w.event(&mut ctx, &event)
}
fn laid_out<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, at: Point, size: Size) {
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(size));
let mut scene = crate::test_support::RecordingScene::default();
let mut pctx = PaintCtx::new(at, size);
w.paint(&mut pctx, &mut scene);
}
#[test]
fn build_registers_and_teardown_unregisters() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
let mut counter = 0u64;
let mut ctx = BuildCtx::new(&mut counter);
let mut w = view.build(&mut ctx);
assert!(coordinator.registered_targets().contains(&w.id));
view.teardown(&mut w, &mut BuildCtx::new(&mut counter));
assert!(!coordinator.registered_targets().contains(&w.id));
}
#[test]
fn enter_hover_leave_fire_for_a_matching_payload() {
let coordinator = DragCoordinator::new();
let entered = Rc::new(RefCell::new(0u32));
let left = Rc::new(RefCell::new(0u32));
let hovered = Rc::new(RefCell::new(Vec::new()));
let e2 = Rc::clone(&entered);
let l2 = Rc::clone(&left);
let h2 = Rc::clone(&hovered);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
.on_hover(move |_s: &mut App, p: Point| h2.borrow_mut().push(p));
let mut w = build(&view);
laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::new(0.0, 0.0));
coordinator.begin(42u32);
coordinator.set_hovered(Some(w.id));
coordinator.update_pointer(Point::new(8.0, 9.0));
housekeeping(&mut w, &mut state);
assert_eq!(
*entered.borrow(),
1,
"on_enter fires once for the matching type"
);
assert_eq!(
hovered.borrow().last(),
Some(&Point::new(3.0, 4.0)),
"on_hover converts the window-space pointer to local space"
);
coordinator.set_hovered(None);
housekeeping(&mut w, &mut state);
assert_eq!(*left.borrow(), 1, "on_leave fires once hover ends");
}
#[test]
fn mismatched_payload_type_is_never_offered() {
let coordinator = DragCoordinator::new();
let entered = Rc::new(RefCell::new(0u32));
let e2 = Rc::clone(&entered);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_enter(move |_s: &mut App| *e2.borrow_mut() += 1);
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin("not-a-u32");
coordinator.set_hovered(Some(w.id));
housekeeping(&mut w, &mut state);
assert_eq!(
*entered.borrow(),
0,
"a mismatched payload type must never fire on_enter"
);
assert_eq!(
w.current_highlight(),
None,
"a mismatched payload type must never highlight either"
);
}
#[test]
fn accepts_false_rejects_the_drop() {
let coordinator = DragCoordinator::new();
let dropped = Rc::new(RefCell::new(Vec::new()));
let d2 = Rc::clone(&dropped);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.accepts(|v: &u32| *v < 10)
.on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(99u32);
coordinator.set_hovered(Some(w.id));
assert_eq!(w.current_highlight(), Some(DragHighlight::Reject));
coordinator.drop();
housekeeping(&mut w, &mut state);
assert!(
dropped.borrow().is_empty(),
"a rejected drop must not fire on_drop"
);
assert_eq!(coordinator.phase(), DragPhase::Idle);
}
#[test]
fn accepts_true_completes_the_drop() {
let coordinator = DragCoordinator::new();
let dropped = Rc::new(RefCell::new(Vec::new()));
let d2 = Rc::clone(&dropped);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(7u32);
coordinator.set_hovered(Some(w.id));
assert_eq!(w.current_highlight(), Some(DragHighlight::Hover));
coordinator.drop();
assert_eq!(w.current_highlight(), Some(DragHighlight::Accept));
housekeeping(&mut w, &mut state);
assert_eq!(*dropped.borrow(), vec![7]);
assert_eq!(coordinator.phase(), DragPhase::Idle);
}
#[test]
fn paint_reports_window_space_bounds() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(20.0), Some(15.0)), coordinator.clone());
let mut w = build(&view);
laid_out(&mut w, Point::new(30.0, 40.0), Size::new(20.0, 15.0));
assert_eq!(
coordinator.target_bounds(w.id),
Some(Rect::from_origin_size(
Point::new(30.0, 40.0),
Size::new(20.0, 15.0)
))
);
}
#[test]
fn custom_highlight_overrides_the_default() {
let coordinator = DragCoordinator::new();
let seen = Rc::new(RefCell::new(Vec::new()));
let seen2 = Rc::clone(&seen);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.accepts(|v: &u32| *v < 10)
.highlight(move |_scene, _size, state| seen2.borrow_mut().push(state));
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(99u32);
coordinator.set_hovered(Some(w.id));
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
assert_eq!(*seen.borrow(), vec![DragHighlight::Reject]);
}
#[test]
fn external_files_session_fires_callbacks_and_completes_drop() {
use std::path::PathBuf;
let coordinator = DragCoordinator::new();
let entered = Rc::new(RefCell::new(0u32));
let dropped = Rc::new(RefCell::new(Vec::new()));
let e2 = Rc::clone(&entered);
let d2 = Rc::clone(&dropped);
let view: DragTargetView<App, Vec<PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
.on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
let mut counter = 0u64;
let mut w = view.build(&mut BuildCtx::new(&mut counter));
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
coordinator.begin_external(paths.clone(), Point::ZERO);
coordinator.set_hovered(Some(w.id));
housekeeping(&mut w, &mut state);
assert_eq!(
*entered.borrow(),
1,
"on_enter fires once for an external files session"
);
assert_eq!(
w.current_highlight(),
Some(DragHighlight::Hover),
"external files session shows Hover highlight when hovered"
);
coordinator.drop();
assert_eq!(
w.current_highlight(),
Some(DragHighlight::Accept),
"external files session shows Accept highlight during drop"
);
housekeeping(&mut w, &mut state);
assert_eq!(
*dropped.borrow(),
paths,
"on_drop receives exactly the external file paths"
);
assert_eq!(
coordinator.phase(),
DragPhase::Idle,
"phase returns to Idle after drop completes"
);
}
#[test]
fn a_file_drop_hover_then_drop_delivers_the_dropped_paths() {
use std::path::PathBuf;
let coordinator = DragCoordinator::new();
let entered = Rc::new(RefCell::new(0u32));
let dropped = Rc::new(RefCell::new(Vec::new()));
let e2 = Rc::clone(&entered);
let d2 = Rc::clone(&dropped);
let view: DragTargetView<App, Vec<PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
.on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
let mut counter = 0u64;
let mut w = view.build(&mut BuildCtx::new(&mut counter));
laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
let mut state = App;
let result = file_drop(
&mut w,
&mut state,
FileDropPhase::Hover,
Point::ZERO,
vec![],
);
assert_eq!(result, EventResult::Handled);
assert_eq!(
coordinator.phase(),
DragPhase::Dragging,
"hovering a target opens an external session"
);
assert_eq!(
*entered.borrow(),
1,
"on_enter fires on the same hover that opened the session"
);
let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
let result = file_drop(
&mut w,
&mut state,
FileDropPhase::Drop,
Point::ZERO,
paths.clone(),
);
assert_eq!(result, EventResult::Handled);
assert_eq!(
*dropped.borrow(),
paths,
"on_drop receives the dropped paths, not the empty hover list"
);
assert_eq!(coordinator.phase(), DragPhase::Idle);
}
#[test]
fn a_file_drop_cancel_fires_leave_and_ends_the_session() {
use std::path::PathBuf;
let coordinator = DragCoordinator::new();
let left = Rc::new(RefCell::new(0u32));
let l2 = Rc::clone(&left);
let view: DragTargetView<App, Vec<PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
let mut counter = 0u64;
let mut w = view.build(&mut BuildCtx::new(&mut counter));
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
file_drop(
&mut w,
&mut state,
FileDropPhase::Hover,
Point::ZERO,
vec![],
);
assert_eq!(coordinator.phase(), DragPhase::Dragging);
file_drop(
&mut w,
&mut state,
FileDropPhase::Cancel,
Point::ZERO,
vec![],
);
assert_eq!(
*left.borrow(),
1,
"cancelling a hovered external session fires on_leave once"
);
assert_eq!(coordinator.phase(), DragPhase::Idle);
}
#[test]
fn a_click_drops_an_accepted_session_with_no_pointer_travel() {
let coordinator = DragCoordinator::new();
let dropped = Rc::new(RefCell::new(Vec::new()));
let d2 = Rc::clone(&dropped);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.lift(source);
coordinator.begin(7u32);
let result = click(&mut w, &mut state);
assert_eq!(result, EventResult::Handled);
assert_eq!(*dropped.borrow(), vec![7], "the click delivered the drop");
assert_eq!(coordinator.phase(), DragPhase::Idle);
}
#[test]
fn a_click_with_no_live_session_falls_through_to_the_child() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
assert_eq!(click(&mut w, &mut state), EventResult::Ignored);
}
#[test]
fn a_click_with_a_rejected_payload_does_not_drop() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.accepts(|v: &u32| *v < 10);
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(99u32);
click(&mut w, &mut state);
assert_eq!(
coordinator.phase(),
DragPhase::Dragging,
"a rejected click leaves the session exactly as it was"
);
}
#[test]
fn semantics_label_and_action_track_acceptance() {
use frust_core::accesskit::{Action, Role};
let coordinator = DragCoordinator::new();
let logic_coordinator = coordinator.clone();
let mut logic = move |_s: &mut App| {
drag_target::<u32, App, _>(
SizedBox::<App>(Some(10.0), Some(10.0)),
logic_coordinator.clone(),
)
};
let mut root: frust_core::RenderRoot<App, DragTargetView<App, u32>> =
frust_core::RenderRoot::new();
let mut state = App;
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(100.0, 100.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Group)
.expect("a Group node was pushed");
assert_eq!(node.label(), Some("drop target"));
assert!(
!node.supports_action(Action::Click),
"no session: no action"
);
let source = coordinator.new_source_id();
coordinator.lift(source);
coordinator.begin(7u32);
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(100.0, 100.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Group)
.expect("a Group node was pushed");
assert_eq!(node.label(), Some("accepts drop"));
assert!(
node.supports_action(Action::Click),
"accepting: Click is offered"
);
}
fn painted_at<S: 'static, T: 'static>(
w: &mut DragTargetWidget<S, T>,
at: Point,
size: Size,
frame: frust_core::FrameTime,
visible: Option<Rect>,
) {
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::tight(size));
let mut scene = crate::test_support::RecordingScene::default();
let mut pctx = PaintCtx::for_test(at, size, frame);
if let Some(visible) = visible {
pctx.constrain_visible_rect(visible);
}
w.paint(&mut pctx, &mut scene);
}
fn frame(n: u64) -> frust_core::FrameTime {
frust_core::FrameTime::from_nanos(n * 16_000_000)
}
#[test]
fn paint_reports_bounds_clipped_to_the_visible_rect() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let mut w = build(&view);
painted_at(
&mut w,
Point::new(0.0, 0.0),
Size::new(100.0, 100.0),
frame(1),
Some(Rect::new(0.0, 60.0, 400.0, 400.0)),
);
assert_eq!(
coordinator.target_bounds(w.id),
Some(Rect::new(0.0, 60.0, 100.0, 100.0)),
"only the part inside the ancestor's visible rect is reported"
);
assert_eq!(
coordinator.target_at(Point::new(50.0, 30.0)),
None,
"the clipped-away part does not resolve"
);
assert_eq!(coordinator.target_at(Point::new(50.0, 80.0)), Some(w.id));
}
#[test]
fn a_target_clipped_to_nothing_reports_no_bounds_and_is_not_resolved() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, u32> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let mut w = build(&view);
painted_at(&mut w, Point::ZERO, Size::new(100.0, 100.0), frame(1), None);
assert!(coordinator.target_bounds(w.id).is_some());
painted_at(
&mut w,
Point::ZERO,
Size::new(100.0, 100.0),
frame(2),
Some(Rect::new(0.0, 200.0, 400.0, 400.0)),
);
assert_eq!(coordinator.target_bounds(w.id), None);
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(1u32);
coordinator.update_pointer(Point::new(50.0, 50.0));
assert_eq!(coordinator.state().session().and_then(|s| s.hovered), None);
}
#[test]
fn a_target_that_did_not_paint_this_frame_is_not_resolved() {
let coordinator = DragCoordinator::new();
let first_view: DragTargetView<App, u32> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let second_view: DragTargetView<App, u32> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let mut first = build(&first_view);
let mut second = build(&second_view);
let size = Size::new(100.0, 100.0);
painted_at(&mut first, Point::ZERO, size, frame(1), None);
painted_at(&mut second, Point::new(200.0, 0.0), size, frame(1), None);
assert_eq!(
coordinator.target_at(Point::new(50.0, 50.0)),
Some(first.id)
);
painted_at(&mut second, Point::new(200.0, 0.0), size, frame(2), None);
assert_eq!(coordinator.target_at(Point::new(50.0, 50.0)), None);
assert_eq!(
coordinator.target_at(Point::new(250.0, 50.0)),
Some(second.id)
);
}
#[test]
fn overlapping_targets_of_different_types_resolve_by_the_payload_type() {
let coordinator = DragCoordinator::new();
let numbers: DragTargetView<App, u32> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let words: DragTargetView<App, String> = drag_target(
SizedBox::<App>(Some(100.0), Some(100.0)),
coordinator.clone(),
);
let mut numbers = build(&numbers);
let mut words = build(&words);
let size = Size::new(100.0, 100.0);
painted_at(&mut numbers, Point::ZERO, size, frame(1), None);
painted_at(&mut words, Point::ZERO, size, frame(1), None);
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(5u32);
coordinator.update_pointer(Point::new(50.0, 50.0));
assert_eq!(
coordinator.state().session().and_then(|s| s.hovered),
Some(numbers.id)
);
coordinator.cancel();
coordinator.lift(source);
coordinator.begin(String::from("word"));
coordinator.move_to_next_target();
coordinator.move_to_next_target();
assert_eq!(
coordinator.state().session().and_then(|s| s.hovered),
Some(words.id),
"keyboard cycling only ever lands on the String target"
);
}
#[test]
fn a_rejected_drop_on_a_never_entered_target_fires_no_leave() {
let coordinator = DragCoordinator::new();
let left = Rc::new(RefCell::new(0u32));
let l2 = Rc::clone(&left);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.accepts(|v: &u32| *v < 10)
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(99u32);
coordinator.set_hovered(Some(w.id));
coordinator.drop();
housekeeping(&mut w, &mut state);
assert_eq!(coordinator.phase(), DragPhase::Idle, "the drop is refused");
assert_eq!(*left.borrow(), 0, "no on_leave without an on_enter");
}
#[test]
fn a_rejected_drop_on_an_entered_target_fires_exactly_one_leave() {
let coordinator = DragCoordinator::new();
let entered = Rc::new(RefCell::new(0u32));
let left = Rc::new(RefCell::new(0u32));
let e2 = Rc::clone(&entered);
let l2 = Rc::clone(&left);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.accepts(|v: &u32| *v < 10)
.on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin(99u32);
coordinator.set_hovered(Some(w.id));
housekeeping(&mut w, &mut state);
assert_eq!(*entered.borrow(), 1);
coordinator.drop();
housekeeping(&mut w, &mut state);
housekeeping(&mut w, &mut state);
assert_eq!(coordinator.phase(), DragPhase::Idle);
assert_eq!(*left.borrow(), 1, "one on_leave closes the one on_enter");
}
#[test]
fn a_non_t_payload_dropped_here_shows_no_reject_and_fires_no_leave() {
let coordinator = DragCoordinator::new();
let left = Rc::new(RefCell::new(0u32));
let l2 = Rc::clone(&left);
let view: DragTargetView<App, u32> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
coordinator.arm(source, Point::ZERO);
coordinator.begin("not-a-u32");
coordinator.set_hovered(Some(w.id));
housekeeping(&mut w, &mut state);
coordinator.drop();
assert_eq!(
w.current_highlight(),
None,
"a non-T payload never reaches the highlight seam, dropped or not"
);
housekeeping(&mut w, &mut state);
assert_eq!(coordinator.phase(), DragPhase::Idle);
assert_eq!(*left.borrow(), 0);
}
fn ended<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
let event = InputEvent::FileDrop(FileDropEvent {
phase: FileDropPhase::Ended,
position: Point::ZERO,
paths: Vec::new(),
});
w.event(&mut ctx, &event)
}
#[test]
fn the_ended_broadcast_ends_a_hovering_external_session_exactly_once() {
use std::path::PathBuf;
let coordinator = DragCoordinator::new();
let left = Rc::new(RefCell::new(0u32));
let dropped = Rc::new(RefCell::new(0u32));
let (l2, d2) = (Rc::clone(&left), Rc::clone(&dropped));
let hovered_view: DragTargetView<App, Vec<PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
.on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
.on_drop(move |_s: &mut App, _paths: Vec<PathBuf>| *d2.borrow_mut() += 1);
let other_view: DragTargetView<App, Vec<PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
let mut hovered = build(&hovered_view);
let mut other = build(&other_view);
laid_out(&mut hovered, Point::ZERO, Size::new(10.0, 10.0));
laid_out(&mut other, Point::new(50.0, 0.0), Size::new(10.0, 10.0));
let mut state = App;
file_drop(
&mut hovered,
&mut state,
FileDropPhase::Hover,
Point::new(5.0, 5.0),
vec![],
);
assert_eq!(
coordinator.state().session().and_then(|s| s.hovered),
Some(hovered.id)
);
assert_eq!(ended(&mut other, &mut state), EventResult::Ignored);
assert_eq!(
coordinator.phase(),
DragPhase::Idle,
"the first target ended it"
);
assert_eq!(ended(&mut hovered, &mut state), EventResult::Ignored);
assert_eq!(coordinator.phase(), DragPhase::Idle);
assert_eq!(
*left.borrow(),
1,
"the hovered target's on_leave fired once"
);
assert_eq!(*dropped.borrow(), 0, "nothing accepted the drop");
}
#[test]
fn file_drop_events_leave_an_in_app_session_untouched() {
let coordinator = DragCoordinator::new();
let view: DragTargetView<App, Vec<std::path::PathBuf>> =
drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
let mut w = build(&view);
laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
let mut state = App;
let source = coordinator.new_source_id();
let sessions: [&dyn Fn(); 3] = [
&|| {
coordinator.arm(source, Point::new(1.0, 1.0));
},
&|| {
coordinator.arm(source, Point::new(1.0, 1.0));
coordinator.begin(3_u32);
},
&|| {
coordinator.lift(source);
coordinator.begin(4_u32);
},
];
for start in sessions {
start();
let before = coordinator.state();
for phase in [
FileDropPhase::Hover,
FileDropPhase::Drop,
FileDropPhase::Cancel,
] {
let result = file_drop(
&mut w,
&mut state,
phase,
Point::new(5.0, 5.0),
vec![std::path::PathBuf::from("/tmp/a.txt")],
);
assert_eq!(result, EventResult::Ignored, "{phase:?} is not ours");
assert_eq!(coordinator.state(), before, "{phase:?} touched nothing");
}
ended(&mut w, &mut state);
assert_eq!(coordinator.state(), before, "nor did the Ended follow-up");
coordinator.cancel();
}
}
struct FileApp {
coordinator: DragCoordinator,
entered: u32,
left: u32,
dropped: Vec<std::path::PathBuf>,
}
fn file_app_logic(state: &mut FileApp) -> crate::StackView<FileApp> {
crate::stack()
.child(SizedBox::<FileApp>(Some(400.0), Some(400.0)))
.child(
drag_target::<Vec<std::path::PathBuf>, FileApp, _>(
SizedBox::<FileApp>(Some(100.0), Some(100.0)),
state.coordinator.clone(),
)
.on_enter(|s: &mut FileApp| s.entered += 1)
.on_leave(|s: &mut FileApp| s.left += 1)
.on_drop(|s: &mut FileApp, paths| s.dropped = paths),
)
}
#[test]
fn an_os_drag_released_away_from_every_target_ends_its_session() {
let (mut root, mut state) = file_app_root();
let event = |phase, x, y, paths| {
InputEvent::FileDrop(FileDropEvent {
phase,
position: Point::new(x, y),
paths,
})
};
for (round, end) in [FileDropPhase::Cancel, FileDropPhase::Drop]
.into_iter()
.enumerate()
{
root.event(&mut state, &event(FileDropPhase::Hover, 50.0, 50.0, vec![]));
assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
assert_eq!(state.entered, round as u32 + 1);
let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
let outcome = root.event(&mut state, &event(end, 300.0, 300.0, paths));
assert!(!outcome.handled, "{end:?} missed the only target");
assert_eq!(
state.coordinator.phase(),
DragPhase::Idle,
"a {end:?} away from every target still ends the session"
);
assert_eq!(state.left, round as u32 + 1, "and the target hears Leave");
assert!(state.dropped.is_empty(), "nothing accepted the paths");
}
let source = state.coordinator.new_source_id();
assert!(state.coordinator.arm(source, Point::new(10.0, 10.0)));
}
fn file_app_root() -> (
frust_core::RenderRoot<FileApp, crate::StackView<FileApp>>,
FileApp,
) {
let mut root: frust_core::RenderRoot<FileApp, crate::StackView<FileApp>> =
frust_core::RenderRoot::new();
let mut state = FileApp {
coordinator: DragCoordinator::new(),
entered: 0,
left: 0,
dropped: Vec::new(),
};
let mut logic: fn(&mut FileApp) -> crate::StackView<FileApp> = file_app_logic;
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(400.0, 400.0));
let mut scene = crate::test_support::RecordingScene::default();
root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
(root, state)
}
fn os_drag(phase: FileDropPhase, at: Point) -> InputEvent {
InputEvent::FileDrop(FileDropEvent {
phase,
position: at,
paths: Vec::new(),
})
}
fn primary(phase: PointerPhase, at: Point) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: at,
button: frust_core::PointerButton::Primary,
})
}
#[test]
fn an_os_drag_whose_hover_ends_without_a_drop_ends_its_session() {
let over = Point::new(50.0, 50.0);
let away = Point::new(300.0, 300.0);
for cursor in [over, away] {
for with_button in [false, true] {
let (mut root, mut state) = file_app_root();
root.event(&mut state, &os_drag(FileDropPhase::Hover, over));
root.event(&mut state, &os_drag(FileDropPhase::Hover, cursor));
assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
assert_eq!(state.entered, 1);
root.event(&mut state, &os_drag(FileDropPhase::Cancel, cursor));
if with_button {
root.event(&mut state, &primary(PointerPhase::Down, cursor));
root.event(&mut state, &primary(PointerPhase::Up, cursor));
}
let case = format!("cursor at {cursor:?}, button event: {with_button}");
assert_eq!(state.coordinator.phase(), DragPhase::Idle, "{case}");
assert_eq!(state.left, 1, "{case}: the entered target hears Leave");
assert!(state.dropped.is_empty(), "{case}: nothing was dropped");
let source = state.coordinator.new_source_id();
assert!(
state.coordinator.arm(source, Point::new(10.0, 10.0)),
"{case}: an in-app drag can start again"
);
}
}
}
#[test]
fn an_os_drag_hover_left_standing_blocks_in_app_drags() {
let (mut root, mut state) = file_app_root();
let away = Point::new(300.0, 300.0);
root.event(
&mut state,
&os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
);
root.event(&mut state, &primary(PointerPhase::Down, away));
root.event(&mut state, &primary(PointerPhase::Up, away));
assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
let source = state.coordinator.new_source_id();
assert!(!state.coordinator.arm(source, Point::new(10.0, 10.0)));
}
struct TwoGroups {
a: DragCoordinator,
b: DragCoordinator,
a_left: u32,
a_dropped: Option<Vec<std::path::PathBuf>>,
b_dropped: Option<Vec<std::path::PathBuf>>,
}
fn two_groups_logic(state: &mut TwoGroups) -> crate::StackView<TwoGroups> {
crate::stack()
.child(SizedBox::<TwoGroups>(Some(400.0), Some(400.0)))
.child(
crate::row()
.child(
drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
state.a.clone(),
)
.on_leave(|s: &mut TwoGroups| s.a_left += 1)
.on_drop(|s: &mut TwoGroups, paths| s.a_dropped = Some(paths)),
)
.child(
drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
state.b.clone(),
)
.on_drop(|s: &mut TwoGroups, paths| s.b_dropped = Some(paths)),
),
)
}
#[test]
fn a_drop_handled_by_one_coordinator_still_ends_anothers_session() {
let mut root: frust_core::RenderRoot<TwoGroups, crate::StackView<TwoGroups>> =
frust_core::RenderRoot::new();
let mut state = TwoGroups {
a: DragCoordinator::new(),
b: DragCoordinator::new(),
a_left: 0,
a_dropped: None,
b_dropped: None,
};
let mut logic: fn(&mut TwoGroups) -> crate::StackView<TwoGroups> = two_groups_logic;
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(400.0, 400.0));
let mut scene = crate::test_support::RecordingScene::default();
root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
root.event(
&mut state,
&os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
);
assert_eq!(state.a.phase(), DragPhase::Dragging, "a opened a session");
assert_eq!(state.b.phase(), DragPhase::Idle);
root.event(
&mut state,
&os_drag(FileDropPhase::Hover, Point::new(150.0, 50.0)),
);
assert_eq!(state.b.phase(), DragPhase::Dragging, "and so did b");
let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
let outcome = root.event(
&mut state,
&InputEvent::FileDrop(FileDropEvent {
phase: FileDropPhase::Drop,
position: Point::new(150.0, 50.0),
paths: paths.clone(),
}),
);
assert!(outcome.handled, "b's target handled the Drop");
assert_eq!(state.b_dropped, Some(paths), "b completed its drop");
assert_eq!(state.b.phase(), DragPhase::Idle);
assert_eq!(state.a.phase(), DragPhase::Idle, "a's session ended too");
assert_eq!(state.a_left, 1, "with the Leave a's hovered target is owed");
assert_eq!(state.a_dropped, None, "a was not dropped on");
for coordinator in [&state.a, &state.b] {
let source = coordinator.new_source_id();
assert!(coordinator.arm(source, Point::new(10.0, 10.0)));
}
}
struct Kanban {
coordinator: DragCoordinator,
a_entered: u32,
a_dropped: Option<u32>,
b_entered: u32,
b_dropped: Option<u32>,
}
fn kanban_logic(state: &mut Kanban) -> crate::StackView<Kanban> {
let card = super::super::draggable::draggable(
SizedBox::<Kanban>(Some(80.0), Some(40.0)),
state.coordinator.clone(),
|_: &Kanban| 7u32,
);
let a = drag_target::<u32, Kanban, _>(
SizedBox::<Kanban>(Some(100.0), Some(100.0)).child(crate::column().child(card)),
state.coordinator.clone(),
)
.on_enter(|s: &mut Kanban| s.a_entered += 1)
.on_drop(|s: &mut Kanban, v: u32| s.a_dropped = Some(v));
let b = drag_target::<u32, Kanban, _>(
SizedBox::<Kanban>(Some(100.0), Some(100.0)),
state.coordinator.clone(),
)
.on_enter(|s: &mut Kanban| s.b_entered += 1)
.on_drop(|s: &mut Kanban, v: u32| s.b_dropped = Some(v));
crate::stack()
.child(SizedBox::<Kanban>(Some(400.0), Some(400.0)))
.child(crate::row().child(a).child(b))
}
struct KanbanHarness {
root: frust_core::RenderRoot<Kanban, crate::StackView<Kanban>>,
state: Kanban,
clock_ms: f64,
}
impl KanbanHarness {
fn new() -> Self {
let mut h = KanbanHarness {
root: frust_core::RenderRoot::new(),
state: Kanban {
coordinator: DragCoordinator::new(),
a_entered: 0,
a_dropped: None,
b_entered: 0,
b_dropped: None,
},
clock_ms: 0.0,
};
h.frame();
h
}
fn frame(&mut self) {
let mut build: fn(&mut Kanban) -> crate::StackView<Kanban> = kanban_logic;
self.root.rebuild(&mut build, &mut self.state);
self.root.layout(Size::new(400.0, 400.0));
self.clock_ms += 16.0;
let mut scene = crate::test_support::RecordingScene::default();
self.root.paint(
&mut scene,
frust_core::FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
);
}
fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root
.event(&mut self.state, &primary(phase, Point::new(x, y)));
}
fn touch(&mut self, phase: PointerPhase, x: f64, y: f64) {
use frust_core::event::PointerId;
self.root.event(
&mut self.state,
&InputEvent::PointerContact {
pointer_id: PointerId::touch(0),
event: frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: frust_core::PointerButton::Primary,
},
},
);
}
}
#[test]
fn a_mouse_drag_dropped_on_the_sibling_column_fires_only_its_on_drop() {
let mut h = KanbanHarness::new();
h.mouse(PointerPhase::Down, 20.0, 20.0);
h.mouse(PointerPhase::Move, 30.0, 20.0);
assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
h.mouse(PointerPhase::Move, 150.0, 50.0);
let a_entered_before_release = h.state.a_entered;
h.mouse(PointerPhase::Up, 150.0, 50.0);
h.frame();
assert_eq!(
h.state.coordinator.phase(),
DragPhase::Idle,
"b claimed the drop"
);
assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
assert_eq!(
h.state.a_dropped, None,
"a's on_drop never fires — the card left it before release"
);
assert_eq!(
h.state.a_entered, a_entered_before_release,
"the Up must not re-enter the source column (the old click-to-drop bug)"
);
}
#[test]
fn a_touch_long_press_dropped_on_the_sibling_column_fires_only_its_on_drop() {
let mut h = KanbanHarness::new();
h.touch(PointerPhase::Down, 20.0, 20.0);
let mut frames = 0;
while h.state.coordinator.phase() != DragPhase::Dragging {
h.frame();
frames += 1;
assert!(frames < 60, "the hold never began a drag");
}
h.touch(PointerPhase::Move, 150.0, 50.0);
h.frame();
let a_entered_before_release = h.state.a_entered;
h.touch(PointerPhase::Up, 150.0, 50.0);
h.frame();
assert_eq!(
h.state.coordinator.phase(),
DragPhase::Idle,
"b claimed the drop"
);
assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
assert_eq!(
h.state.a_dropped, None,
"a's on_drop never fires — the card left it before release"
);
assert_eq!(
h.state.a_entered, a_entered_before_release,
"the Up must not re-enter the source column (the old click-to-drop bug)"
);
}
#[test]
fn a_keyboard_sessions_click_to_drop_still_drops_on_the_cycled_target() {
let mut h = KanbanHarness::new();
let source = h.state.coordinator.new_source_id();
h.state.coordinator.lift(source);
h.state.coordinator.begin(7u32);
h.state.coordinator.move_to_next_target();
h.state.coordinator.move_to_next_target();
h.frame();
assert_eq!(h.state.b_entered, 1, "cycling landed the hover on b");
h.mouse(PointerPhase::Up, 150.0, 50.0);
assert_eq!(h.state.b_dropped, Some(7), "the click delivered the drop");
assert_eq!(h.state.coordinator.phase(), DragPhase::Idle);
}
}