Skip to main content

lumen_os_dnd/
lib.rs

1//! Drag-and-drop abstractions for Lumen.
2//!
3//! Reuses the [`MimePayload`] type from [`lumen-os-mime`] so a single
4//! payload travels across both the clipboard and the DnD pipeline -
5//! mirrors `QMimeData` / `GdkContentProvider`.
6//!
7//! In-app drag from a [`DragSource`] entity to a `lumen-core::DropTarget`
8//! is now wired end to end: [`begin_in_app_drag`] opens an [`ActiveDrag`]
9//! session on the drag-gesture threshold (fed by
10//! `lumen-primitives::drag`), [`track_drop_hover`] follows the pointer
11//! marking the hovered target with `DropHovered`, and
12//! [`finish_in_app_drag`] hit-tests the release point and emits
13//! [`DropAccepted`]. The inbound file-drop path continues to work
14//! unchanged via `lumen-core::FileDropped` / `PendingFileDrops`
15//! ([`translate_file_drops_to_payload`]). Cross-window drag-source via
16//! the platform (`NSDraggingSession`, `DoDragDrop`, `wl_data_source`/Xdnd)
17//! is deferred - winit exposes no source API today.
18//!
19//! Mirrors: the source-publishes-mime -> target-negotiates-effect ->
20//! drop-carries-source+data shape of `QDrag`/`QMimeData`/`dropEvent`, and
21//! the HTML5 `dragstart`/`dragenter`/`drop` + `dataTransfer` event model
22//! (so the markup `draggable` / `drop-target` / `on-drop` surface maps
23//! 1:1 onto real DOM DnD when transpiled).
24
25#![forbid(unsafe_code)]
26#![warn(missing_docs)]
27
28use bevy_ecs::hierarchy::ChildOf;
29use bevy_ecs::prelude::*;
30use glam::Vec2;
31use lumen_core::components::{DropHovered, DropTarget, Transform};
32use lumen_core::input::{DragEndEvent, DragStartEvent, FileDropped, PointerState, ScrollOffset};
33use lumen_os_mime::{MimeKind, MimePayload};
34
35pub use lumen_os_mime as mime;
36
37/// Marker / data component for an entity that can be the *source* of a
38/// drag-and-drop. Authored by the markup layer (`<draggable mime=...>`
39/// follow-up) or imperatively by the app.
40///
41/// The payload is built eagerly - for lazy construction (GTK
42/// `DragSource::prepare`), wrap a closure inside a `Custom` blob and
43/// resolve at drag-start time. v1 ships eager.
44#[derive(Component, Clone, Debug)]
45pub struct DragSource {
46    /// The payload exported when a drag begins on this entity.
47    pub payload: MimePayload,
48    /// Allowed effects. v1 only carries the data - backends choose the
49    /// cursor.
50    pub effects: DropEffectSet,
51}
52
53impl DragSource {
54    /// Construct a drag source from any value convertible into a
55    /// [`MimePayload`].
56    pub fn new(payload: impl Into<MimePayload>) -> Self {
57        Self {
58            payload: payload.into(),
59            effects: DropEffectSet::COPY,
60        }
61    }
62
63    /// Builder: limit the effects this source advertises.
64    pub fn with_effects(mut self, effects: DropEffectSet) -> Self {
65        self.effects = effects;
66        self
67    }
68}
69
70/// Bitset of drop effects a source advertises or a target accepts.
71///
72/// Mirrors `Qt::DropAction` / `NSDragOperation` / `Gdk::DragAction`.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
74pub struct DropEffectSet(pub u8);
75
76impl DropEffectSet {
77    /// Empty set - drop is rejected.
78    pub const NONE: Self = DropEffectSet(0);
79    /// Copy the data, leaving the source intact.
80    pub const COPY: Self = DropEffectSet(1 << 0);
81    /// Move the data - source should delete after a successful drop.
82    pub const MOVE: Self = DropEffectSet(1 << 1);
83    /// Link / reference the data without copying.
84    pub const LINK: Self = DropEffectSet(1 << 2);
85    /// Any of the above.
86    pub const ANY: Self = DropEffectSet(0b111);
87
88    /// True when this set contains every bit in `other`.
89    pub fn contains(self, other: Self) -> bool {
90        (self.0 & other.0) == other.0
91    }
92
93    /// Intersection with another set.
94    pub fn intersect(self, other: Self) -> Self {
95        DropEffectSet(self.0 & other.0)
96    }
97
98    /// True when no bits are set.
99    pub fn is_empty(self) -> bool {
100        self.0 == 0
101    }
102}
103
104/// MIME-type filter declared on a [`lumen_core::DropTarget`] (extended
105/// by this crate). Drops are rejected when none of the configured
106/// kinds match the source payload.
107///
108/// Authored separately from the marker component so the existing
109/// `lumen-core::DropTarget` stays untouched; entities can carry both.
110#[derive(Component, Clone, Debug, Default)]
111pub struct DropAccept {
112    /// MIME kinds this target accepts. Empty means "accept any".
113    pub kinds: Vec<MimeKind>,
114    /// Effects this target advertises. Empty (`NONE`) rejects.
115    pub effects: DropEffectSet,
116}
117
118impl DropAccept {
119    /// Accept any MIME - semantics today's `DropTarget` already has.
120    pub fn any() -> Self {
121        Self {
122            kinds: Vec::new(),
123            effects: DropEffectSet::COPY,
124        }
125    }
126
127    /// Accept only the given MIME kinds.
128    pub fn only(kinds: impl IntoIterator<Item = MimeKind>) -> Self {
129        Self {
130            kinds: kinds.into_iter().collect(),
131            effects: DropEffectSet::COPY,
132        }
133    }
134
135    /// Builder: set the allowed effects.
136    pub fn with_effects(mut self, effects: DropEffectSet) -> Self {
137        self.effects = effects;
138        self
139    }
140
141    /// True when this accept-set permits a drag advertising `kinds`.
142    ///
143    /// A hovering drag exposes the kinds it carries before it exposes the
144    /// payload itself (HTML5 `DataTransfer.types` during `dragenter`), which
145    /// is what lets a target answer "would this drop be taken" while the
146    /// pointer is still moving. [`DropAccept::accepts`] is the same question
147    /// asked of a payload that has arrived.
148    pub fn accepts_kinds<'a>(&self, kinds: impl IntoIterator<Item = &'a MimeKind>) -> bool {
149        if self.kinds.is_empty() {
150            return true;
151        }
152        kinds.into_iter().any(|k| self.kinds.contains(k))
153    }
154
155    /// True when this accept-set permits the supplied payload.
156    pub fn accepts(&self, payload: &MimePayload) -> bool {
157        self.accepts_kinds(payload.kinds.iter().map(|(k, _)| k))
158    }
159}
160
161/// Emitted when a drag starts on a [`DragSource`] entity (in-app
162/// gesture). Cross-window drag-source via the platform is deferred -
163/// see crate docs.
164#[derive(Message, Clone, Debug)]
165pub struct DragStarted {
166    /// Source entity carrying [`DragSource`].
167    pub source: Entity,
168    /// Payload the source published.
169    pub payload: MimePayload,
170}
171
172/// Emitted when the user releases the drag on a hovered drop target
173/// (in-app gesture).
174#[derive(Message, Clone, Debug)]
175pub struct DropAccepted {
176    /// Source entity, when the drop originated in-app.
177    pub source: Option<Entity>,
178    /// Target entity receiving the drop.
179    pub target: Entity,
180    /// Payload delivered.
181    pub payload: MimePayload,
182    /// Effect chosen by the target.
183    pub effect: DropEffect,
184}
185
186/// Single resolved drop effect (one bit of [`DropEffectSet`]).
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
188pub enum DropEffect {
189    /// Drop rejected - for parity with `Qt::IgnoreAction`.
190    #[default]
191    None,
192    /// Copy data.
193    Copy,
194    /// Move data.
195    Move,
196    /// Link / reference data.
197    Link,
198}
199
200impl DropEffect {
201    /// Pick a single effect from a set, preferring `Copy` -> `Move` ->
202    /// `Link` -> `None`. Mirrors `NSDragOperation` / `Qt::DropAction`
203    /// default behaviour.
204    pub fn pick(set: DropEffectSet) -> DropEffect {
205        if set.contains(DropEffectSet::COPY) {
206            DropEffect::Copy
207        } else if set.contains(DropEffectSet::MOVE) {
208            DropEffect::Move
209        } else if set.contains(DropEffectSet::LINK) {
210            DropEffect::Link
211        } else {
212            DropEffect::None
213        }
214    }
215}
216
217/// Translate inbound platform `FileDropped` events into [`DropAccepted`]
218/// when the target carries a [`DropAccept`] filter. Targets without a
219/// `DropAccept` keep the old "accept anything" behaviour, so
220/// `apps/drop-target` keeps working.
221///
222/// This is the inbound half, the drag the platform hands the window. The
223/// in-app half - a drag that starts on a [`DragSource`] in the same app -
224/// runs through [`begin_in_app_drag`], [`track_drop_hover`] and
225/// [`finish_in_app_drag`], which [`DndPlugin`] registers alongside this one.
226pub fn translate_file_drops_to_payload(
227    mut file_drops: MessageReader<FileDropped>,
228    accept: Query<&DropAccept>,
229    mut out: MessageWriter<DropAccepted>,
230) {
231    for ev in file_drops.read() {
232        // Build a uri-list payload from the single dropped file -
233        // mirrors what the platform DnD layer would have sent.
234        let payload: MimePayload = vec![ev.path.clone()].into();
235        let effect = if let Ok(da) = accept.get(ev.entity) {
236            if !da.accepts(&payload) {
237                continue;
238            }
239            DropEffect::pick(da.effects)
240        } else {
241            DropEffect::Copy
242        };
243        out.write(DropAccepted {
244            source: None,
245            target: ev.entity,
246            payload: payload.clone(),
247            effect,
248        });
249    }
250}
251
252/// In-flight in-app drag session: the source entity, the payload it
253/// published at drag-start, and the effects it advertises. `None` when no
254/// drag gesture is active.
255///
256/// Holds the payload for the whole gesture the way a platform session
257/// (`NSDraggingSession` / `IDataObject` behind `DoDragDrop`) owns the
258/// dragged data - so the drop can deliver it even though the source
259/// component may have changed underneath a reactive `<for>` rebuild.
260#[derive(Resource, Clone, Debug, Default)]
261pub struct ActiveDrag(pub Option<ActiveDragData>);
262
263/// Payload of an in-flight [`ActiveDrag`].
264#[derive(Clone, Debug)]
265pub struct ActiveDragData {
266    /// Entity that started the drag (carries [`DragSource`]).
267    pub source: Entity,
268    /// Payload captured at drag-start.
269    pub payload: MimePayload,
270    /// Effects the source advertises (negotiated against each target).
271    pub effects: DropEffectSet,
272}
273
274/// Sum every ancestor's [`ScrollOffset`] (excluding the entity's own) so
275/// a target's logical bounds match what the user sees - the same walk
276/// `lumen-input::ancestor_scroll` performs for the file-drop hit-test,
277/// inlined here to keep `lumen-os-dnd` free of an `lumen-input` dep.
278fn ancestor_scroll_offset(
279    entity: Entity,
280    parents: &Query<&ChildOf>,
281    scrolls: &Query<&ScrollOffset>,
282) -> Vec2 {
283    let mut total = Vec2::ZERO;
284    let mut cur = entity;
285    while let Ok(child_of) = parents.get(cur) {
286        let parent = child_of.parent();
287        if let Ok(off) = scrolls.get(parent) {
288            total += off.0;
289        }
290        cur = parent;
291    }
292    total
293}
294
295/// Hit-test `pos` against every [`DropTarget`], returning the topmost
296/// (deepest in the hierarchy, then highest entity id) target that accepts
297/// `payload`, paired with the negotiated [`DropEffect`].
298///
299/// Effect negotiation mirrors `Qt::DropAction` / `acceptProposedAction`:
300/// the source's advertised `effects` are intersected with the target's
301/// [`DropAccept::effects`], then [`DropEffect::pick`] resolves the single
302/// action. A target with no [`DropAccept`] keeps the legacy "accept
303/// anything, Copy" behaviour so the file-drop path and `apps/drop-target`
304/// are unchanged.
305#[allow(clippy::type_complexity)]
306fn topmost_target(
307    pos: Vec2,
308    payload: &MimePayload,
309    source_effects: DropEffectSet,
310    targets: &Query<(Entity, &Transform, Option<&DropAccept>), With<DropTarget>>,
311    parents: &Query<&ChildOf>,
312    scrolls: &Query<&ScrollOffset>,
313) -> Option<(Entity, DropEffect)> {
314    let mut best: Option<(u32, Entity, DropEffect)> = None;
315    for (e, t, accept) in targets.iter() {
316        let off = ancestor_scroll_offset(e, parents, scrolls);
317        let origin = t.absolute - off;
318        if !(pos.x >= origin.x
319            && pos.y >= origin.y
320            && pos.x < origin.x + t.size.x
321            && pos.y < origin.y + t.size.y)
322        {
323            continue;
324        }
325        let effect = match accept {
326            Some(da) => {
327                if !da.accepts(payload) {
328                    continue;
329                }
330                DropEffect::pick(da.effects.intersect(source_effects))
331            }
332            None => DropEffect::pick(source_effects),
333        };
334        if effect == DropEffect::None {
335            continue;
336        }
337        let mut depth = 0u32;
338        let mut cur = e;
339        while let Ok(co) = parents.get(cur) {
340            depth += 1;
341            cur = co.parent();
342        }
343        match best {
344            None => best = Some((depth, e, effect)),
345            Some((bd, be, _)) if (depth, e) > (bd, be) => best = Some((depth, e, effect)),
346            _ => {}
347        }
348    }
349    best.map(|(_, e, eff)| (e, eff))
350}
351
352/// Open an [`ActiveDrag`] session and announce [`DragStarted`] when a
353/// drag gesture crosses the threshold on a [`DragSource`] entity. Mirrors
354/// `QDrag::exec` beginning a drag with the source's `QMimeData` /
355/// HTML5 `dragstart` populating `dataTransfer`.
356pub fn begin_in_app_drag(
357    mut starts: MessageReader<DragStartEvent>,
358    sources: Query<&DragSource>,
359    mut active: ResMut<ActiveDrag>,
360    mut out: MessageWriter<DragStarted>,
361) {
362    for ev in starts.read() {
363        if let Ok(src) = sources.get(ev.entity) {
364            active.0 = Some(ActiveDragData {
365                source: ev.entity,
366                payload: src.payload.clone(),
367                effects: src.effects,
368            });
369            out.write(DragStarted {
370                source: ev.entity,
371                payload: src.payload.clone(),
372            });
373        }
374    }
375}
376
377/// While a drag is active, keep [`DropHovered`] on the topmost accepting
378/// [`DropTarget`] under the pointer (and off every other target). Mirrors
379/// HTML5 `dragenter` / `dragleave` so `:drag-over` styling follows the
380/// cursor.
381pub fn track_drop_hover(
382    mut commands: Commands,
383    active: Res<ActiveDrag>,
384    pointer: Res<PointerState>,
385    targets: Query<(Entity, &Transform, Option<&DropAccept>), With<DropTarget>>,
386    parents: Query<&ChildOf>,
387    scrolls: Query<&ScrollOffset>,
388    hovered: Query<Entity, With<DropHovered>>,
389) {
390    let want = match (&active.0, pointer.position) {
391        (Some(d), Some(pos)) => {
392            topmost_target(pos, &d.payload, d.effects, &targets, &parents, &scrolls).map(|(e, _)| e)
393        }
394        _ => None,
395    };
396    for e in &hovered {
397        if Some(e) != want {
398            commands.entity(e).remove::<DropHovered>();
399        }
400    }
401    if let Some(e) = want
402        && !hovered.contains(e)
403    {
404        commands.entity(e).insert(DropHovered);
405    }
406}
407
408/// On the release edge of an active drag, hit-test the drop point and
409/// emit [`DropAccepted`] at the topmost accepting target - the in-app
410/// twin of [`translate_file_drops_to_payload`]. Mirrors Qt `dropEvent`
411/// carrying the source + `QMimeData` / HTML5 `drop` carrying
412/// `dataTransfer`.
413#[allow(clippy::too_many_arguments)] // ECS system: each arg is a query/param
414pub fn finish_in_app_drag(
415    mut commands: Commands,
416    mut ends: MessageReader<DragEndEvent>,
417    mut active: ResMut<ActiveDrag>,
418    targets: Query<(Entity, &Transform, Option<&DropAccept>), With<DropTarget>>,
419    parents: Query<&ChildOf>,
420    scrolls: Query<&ScrollOffset>,
421    hovered: Query<Entity, With<DropHovered>>,
422    mut out: MessageWriter<DropAccepted>,
423) {
424    for ev in ends.read() {
425        let Some(data) = active.0.clone() else {
426            continue;
427        };
428        if ev.entity != data.source {
429            continue;
430        }
431        if let Some((target, effect)) = topmost_target(
432            ev.position,
433            &data.payload,
434            data.effects,
435            &targets,
436            &parents,
437            &scrolls,
438        ) {
439            out.write(DropAccepted {
440                source: Some(data.source),
441                target,
442                payload: data.payload.clone(),
443                effect,
444            });
445        }
446        active.0 = None;
447        for e in &hovered {
448            commands.entity(e).remove::<DropHovered>();
449        }
450    }
451}
452
453/// Plugin: registers DnD messages + both the platform file-drop -> payload
454/// translator and the in-app drag-gesture -> drop pipeline.
455pub struct DndPlugin;
456
457impl lumen_core::app::Plugin for DndPlugin {
458    fn build(self, app: &mut lumen_core::app::App) {
459        app.add_message::<DragStarted>();
460        app.add_message::<DropAccepted>();
461        app.world.insert_resource(ActiveDrag::default());
462        app.add_systems(
463            lumen_core::tick::TickStage::Systems,
464            translate_file_drops_to_payload,
465        );
466        // In-app gesture path: open the session, resolve the drop, then
467        // refresh the hover marker (so a just-finished drag clears its
468        // `:drag-over` in the same tick).
469        app.add_systems(lumen_core::tick::TickStage::Systems, begin_in_app_drag);
470        app.add_systems(
471            lumen_core::tick::TickStage::Systems,
472            finish_in_app_drag.after(begin_in_app_drag),
473        );
474        app.add_systems(
475            lumen_core::tick::TickStage::Systems,
476            track_drop_hover.after(finish_in_app_drag),
477        );
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn drop_effect_set_basics() {
487        let s = DropEffectSet::COPY;
488        assert!(s.contains(DropEffectSet::COPY));
489        assert!(!s.contains(DropEffectSet::MOVE));
490        assert!(!s.is_empty());
491        assert!(DropEffectSet::NONE.is_empty());
492
493        let any = DropEffectSet::ANY;
494        assert!(any.contains(DropEffectSet::COPY));
495        assert!(any.contains(DropEffectSet::MOVE));
496        assert!(any.contains(DropEffectSet::LINK));
497    }
498
499    #[test]
500    fn drop_effect_pick_priority() {
501        assert_eq!(DropEffect::pick(DropEffectSet::ANY), DropEffect::Copy);
502        assert_eq!(DropEffect::pick(DropEffectSet::MOVE), DropEffect::Move);
503        assert_eq!(DropEffect::pick(DropEffectSet::LINK), DropEffect::Link);
504        assert_eq!(DropEffect::pick(DropEffectSet::NONE), DropEffect::None);
505    }
506
507    #[test]
508    fn drop_accept_filters_by_mime() {
509        let a = DropAccept::only([MimeKind::TextUriList]);
510        let path_payload: MimePayload = vec![std::path::PathBuf::from("/x")].into();
511        let text_payload: MimePayload = "hello".into();
512        assert!(a.accepts(&path_payload));
513        assert!(!a.accepts(&text_payload));
514    }
515
516    /// A hovering drag names what it carries before it hands the data over,
517    /// which is the question a `:drag-over` marker is the answer to.
518    #[test]
519    fn drop_accept_filters_a_hovering_drag_by_the_kinds_it_advertises() {
520        let a = DropAccept::only([MimeKind::TextUriList]);
521        let both = [&MimeKind::TextPlain, &MimeKind::TextUriList];
522        assert!(a.accepts_kinds([&MimeKind::TextUriList]));
523        assert!(a.accepts_kinds(both));
524        assert!(!a.accepts_kinds([&MimeKind::TextPlain]));
525        assert!(!a.accepts_kinds([]), "a drag naming nothing is not a match");
526        assert!(
527            DropAccept::any().accepts_kinds([]),
528            "a target filtering nothing takes it anyway"
529        );
530    }
531
532    #[test]
533    fn drop_accept_any_accepts_everything() {
534        let a = DropAccept::any();
535        let p: MimePayload = "x".into();
536        assert!(a.accepts(&p));
537        assert!(a.accepts(&MimePayload::new()));
538    }
539
540    // --- in-app drag-gesture -> drop pipeline ---
541    mod in_app {
542        use super::super::*;
543        use bevy_ecs::message::Messages;
544        use bevy_ecs::system::RunSystemOnce;
545        use glam::Vec2;
546        use lumen_core::components::Transform;
547
548        fn setup() -> World {
549            let mut world = World::new();
550            world.init_resource::<Messages<DragStartEvent>>();
551            world.init_resource::<Messages<DragEndEvent>>();
552            world.init_resource::<Messages<DragStarted>>();
553            world.init_resource::<Messages<DropAccepted>>();
554            world.insert_resource(ActiveDrag::default());
555            world.insert_resource(PointerState::default());
556            world
557        }
558
559        fn target(world: &mut World, origin: Vec2, size: Vec2, accept: DropAccept) -> Entity {
560            world
561                .spawn((Transform::new(origin, size), DropTarget, accept))
562                .id()
563        }
564
565        /// Threshold-crossing on a `DragSource` opens the session and
566        /// emits `DragStarted`; releasing over an accepting target emits
567        /// `DropAccepted` carrying source + payload + effect.
568        #[test]
569        fn drag_gesture_to_drop_target_emits_accepted() {
570            let mut world = setup();
571            let src = world.spawn(DragSource::new("card-42")).id();
572            let tgt = target(
573                &mut world,
574                Vec2::new(100.0, 0.0),
575                Vec2::new(100.0, 100.0),
576                DropAccept::only([MimeKind::TextPlain]),
577            );
578
579            world
580                .resource_mut::<Messages<DragStartEvent>>()
581                .write(DragStartEvent {
582                    entity: src,
583                    start: Vec2::new(10.0, 10.0),
584                    position: Vec2::new(20.0, 10.0),
585                });
586            world.run_system_once(begin_in_app_drag).unwrap();
587
588            assert!(world.resource::<ActiveDrag>().0.is_some(), "session open");
589            let started: Vec<DragStarted> = world
590                .resource_mut::<Messages<DragStarted>>()
591                .drain()
592                .collect();
593            assert_eq!(started.len(), 1);
594            assert_eq!(started[0].source, src);
595            assert_eq!(started[0].payload.text().as_deref(), Some("card-42"));
596
597            // Release inside the target.
598            world
599                .resource_mut::<Messages<DragEndEvent>>()
600                .write(DragEndEvent {
601                    entity: src,
602                    position: Vec2::new(150.0, 50.0),
603                });
604            world.run_system_once(finish_in_app_drag).unwrap();
605
606            let dropped: Vec<DropAccepted> = world
607                .resource_mut::<Messages<DropAccepted>>()
608                .drain()
609                .collect();
610            assert_eq!(dropped.len(), 1, "one DropAccepted");
611            assert_eq!(dropped[0].source, Some(src));
612            assert_eq!(dropped[0].target, tgt);
613            assert_eq!(dropped[0].payload.text().as_deref(), Some("card-42"));
614            assert_eq!(dropped[0].effect, DropEffect::Copy);
615            assert!(world.resource::<ActiveDrag>().0.is_none(), "session closed");
616        }
617
618        /// Releasing outside every target drops nothing and closes the
619        /// session (drag cancelled).
620        #[test]
621        fn release_outside_targets_is_cancel() {
622            let mut world = setup();
623            let src = world.spawn(DragSource::new("x")).id();
624            target(
625                &mut world,
626                Vec2::new(0.0, 0.0),
627                Vec2::new(50.0, 50.0),
628                DropAccept::any(),
629            );
630            world
631                .resource_mut::<Messages<DragStartEvent>>()
632                .write(DragStartEvent {
633                    entity: src,
634                    start: Vec2::ZERO,
635                    position: Vec2::new(10.0, 10.0),
636                });
637            world.run_system_once(begin_in_app_drag).unwrap();
638            world
639                .resource_mut::<Messages<DragEndEvent>>()
640                .write(DragEndEvent {
641                    entity: src,
642                    position: Vec2::new(500.0, 500.0),
643                });
644            world.run_system_once(finish_in_app_drag).unwrap();
645            assert!(
646                world
647                    .resource_mut::<Messages<DropAccepted>>()
648                    .drain()
649                    .next()
650                    .is_none()
651            );
652            assert!(world.resource::<ActiveDrag>().0.is_none());
653        }
654
655        /// A target whose MIME filter rejects the payload is skipped.
656        #[test]
657        fn mime_filter_rejects_drop() {
658            let mut world = setup();
659            let src = world.spawn(DragSource::new("plain text")).id();
660            target(
661                &mut world,
662                Vec2::new(0.0, 0.0),
663                Vec2::new(100.0, 100.0),
664                DropAccept::only([MimeKind::ImagePng]),
665            );
666            world
667                .resource_mut::<Messages<DragStartEvent>>()
668                .write(DragStartEvent {
669                    entity: src,
670                    start: Vec2::ZERO,
671                    position: Vec2::new(10.0, 10.0),
672                });
673            world.run_system_once(begin_in_app_drag).unwrap();
674            world
675                .resource_mut::<Messages<DragEndEvent>>()
676                .write(DragEndEvent {
677                    entity: src,
678                    position: Vec2::new(50.0, 50.0),
679                });
680            world.run_system_once(finish_in_app_drag).unwrap();
681            assert!(
682                world
683                    .resource_mut::<Messages<DropAccepted>>()
684                    .drain()
685                    .next()
686                    .is_none(),
687                "text payload rejected by image-only target"
688            );
689        }
690
691        /// `track_drop_hover` marks the target under the pointer with
692        /// `DropHovered` while a drag is active, and clears it when the
693        /// pointer leaves.
694        #[test]
695        fn hover_marker_follows_pointer() {
696            let mut world = setup();
697            let src = world.spawn(DragSource::new("c")).id();
698            let tgt = target(
699                &mut world,
700                Vec2::new(0.0, 0.0),
701                Vec2::new(100.0, 100.0),
702                DropAccept::any(),
703            );
704            world.resource_mut::<ActiveDrag>().0 = Some(ActiveDragData {
705                source: src,
706                payload: "c".into(),
707                effects: DropEffectSet::COPY,
708            });
709
710            world.resource_mut::<PointerState>().position = Some(Vec2::new(50.0, 50.0));
711            world.run_system_once(track_drop_hover).unwrap();
712            world.flush();
713            assert!(world.get::<DropHovered>(tgt).is_some(), "marked on enter");
714
715            world.resource_mut::<PointerState>().position = Some(Vec2::new(500.0, 500.0));
716            world.run_system_once(track_drop_hover).unwrap();
717            world.flush();
718            assert!(world.get::<DropHovered>(tgt).is_none(), "cleared on leave");
719        }
720    }
721
722    #[test]
723    fn drag_source_round_trip() {
724        let src = DragSource::new("hello");
725        assert_eq!(src.payload.text().as_deref(), Some("hello"));
726        assert_eq!(src.effects, DropEffectSet::COPY);
727
728        let src2 = DragSource::new(vec![std::path::PathBuf::from("/tmp/a")])
729            .with_effects(DropEffectSet::MOVE);
730        assert!(src2.payload.has(&MimeKind::TextUriList));
731        assert_eq!(src2.effects, DropEffectSet::MOVE);
732    }
733}