1#![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#[derive(Component, Clone, Debug)]
45pub struct DragSource {
46 pub payload: MimePayload,
48 pub effects: DropEffectSet,
51}
52
53impl DragSource {
54 pub fn new(payload: impl Into<MimePayload>) -> Self {
57 Self {
58 payload: payload.into(),
59 effects: DropEffectSet::COPY,
60 }
61 }
62
63 pub fn with_effects(mut self, effects: DropEffectSet) -> Self {
65 self.effects = effects;
66 self
67 }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
74pub struct DropEffectSet(pub u8);
75
76impl DropEffectSet {
77 pub const NONE: Self = DropEffectSet(0);
79 pub const COPY: Self = DropEffectSet(1 << 0);
81 pub const MOVE: Self = DropEffectSet(1 << 1);
83 pub const LINK: Self = DropEffectSet(1 << 2);
85 pub const ANY: Self = DropEffectSet(0b111);
87
88 pub fn contains(self, other: Self) -> bool {
90 (self.0 & other.0) == other.0
91 }
92
93 pub fn intersect(self, other: Self) -> Self {
95 DropEffectSet(self.0 & other.0)
96 }
97
98 pub fn is_empty(self) -> bool {
100 self.0 == 0
101 }
102}
103
104#[derive(Component, Clone, Debug, Default)]
111pub struct DropAccept {
112 pub kinds: Vec<MimeKind>,
114 pub effects: DropEffectSet,
116}
117
118impl DropAccept {
119 pub fn any() -> Self {
121 Self {
122 kinds: Vec::new(),
123 effects: DropEffectSet::COPY,
124 }
125 }
126
127 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 pub fn with_effects(mut self, effects: DropEffectSet) -> Self {
137 self.effects = effects;
138 self
139 }
140
141 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 pub fn accepts(&self, payload: &MimePayload) -> bool {
157 self.accepts_kinds(payload.kinds.iter().map(|(k, _)| k))
158 }
159}
160
161#[derive(Message, Clone, Debug)]
165pub struct DragStarted {
166 pub source: Entity,
168 pub payload: MimePayload,
170}
171
172#[derive(Message, Clone, Debug)]
175pub struct DropAccepted {
176 pub source: Option<Entity>,
178 pub target: Entity,
180 pub payload: MimePayload,
182 pub effect: DropEffect,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
188pub enum DropEffect {
189 #[default]
191 None,
192 Copy,
194 Move,
196 Link,
198}
199
200impl DropEffect {
201 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
217pub 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 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#[derive(Resource, Clone, Debug, Default)]
261pub struct ActiveDrag(pub Option<ActiveDragData>);
262
263#[derive(Clone, Debug)]
265pub struct ActiveDragData {
266 pub source: Entity,
268 pub payload: MimePayload,
270 pub effects: DropEffectSet,
272}
273
274fn 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#[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
352pub 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
377pub 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#[allow(clippy::too_many_arguments)] pub 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
453pub 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 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 #[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 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 #[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 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 #[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 #[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 #[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}