Skip to main content

dioxus_dnd/core/
collision.rs

1//! Ranked collision detection and release policy.
2
3use dioxus::prelude::Callback;
4
5use super::{Point, Rect, ZoneId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8#[non_exhaustive]
9pub enum CollisionStrategy {
10    #[default]
11    PointerWithin,
12    ClosestCenter,
13    ClosestCorners,
14    RectIntersection,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18#[non_exhaustive]
19pub struct ZoneCandidate {
20    pub id: ZoneId,
21    pub rect: Rect,
22    pub order: usize,
23}
24
25impl ZoneCandidate {
26    pub fn new(id: ZoneId, rect: Rect, order: usize) -> Self {
27        Self { id, rect, order }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32#[non_exhaustive]
33pub struct Collision {
34    pub zone: ZoneId,
35    /// Lower scores rank first.
36    pub score: f64,
37}
38
39impl Collision {
40    pub fn new(zone: ZoneId, score: f64) -> Self {
41        Self { zone, score }
42    }
43}
44
45#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47pub struct CollisionRequest<T> {
48    pub pointer: Point,
49    pub active_rect: Option<Rect>,
50    pub payload: T,
51    pub candidates: Vec<ZoneCandidate>,
52    pub max_distance: f64,
53}
54
55impl<T> CollisionRequest<T> {
56    pub fn new(pointer: Point, payload: T, candidates: Vec<ZoneCandidate>) -> Self {
57        Self {
58            pointer,
59            active_rect: None,
60            payload,
61            candidates,
62            max_distance: 0.0,
63        }
64    }
65}
66
67#[non_exhaustive]
68pub enum CollisionDetector<T: 'static> {
69    BuiltIn(CollisionStrategy),
70    Custom(Callback<CollisionRequest<T>, Vec<Collision>>),
71}
72
73impl<T> Copy for CollisionDetector<T> {}
74impl<T> Clone for CollisionDetector<T> {
75    fn clone(&self) -> Self {
76        *self
77    }
78}
79impl<T> PartialEq for CollisionDetector<T> {
80    fn eq(&self, other: &Self) -> bool {
81        match (*self, *other) {
82            (Self::BuiltIn(a), Self::BuiltIn(b)) => a == b,
83            (Self::Custom(a), Self::Custom(b)) => a == b,
84            _ => false,
85        }
86    }
87}
88
89impl<T> std::fmt::Debug for CollisionDetector<T> {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::BuiltIn(strategy) => f.debug_tuple("BuiltIn").field(strategy).finish(),
93            Self::Custom(_) => f.write_str("Custom(..)"),
94        }
95    }
96}
97
98impl<T> Default for CollisionDetector<T> {
99    fn default() -> Self {
100        Self::BuiltIn(CollisionStrategy::PointerWithin)
101    }
102}
103
104#[derive(Debug)]
105#[non_exhaustive]
106pub struct ReleasePolicy<T: 'static> {
107    pub collision: CollisionDetector<T>,
108    pub recovery_radius: f64,
109    pub sticky: bool,
110}
111
112impl<T> Copy for ReleasePolicy<T> {}
113impl<T> Clone for ReleasePolicy<T> {
114    fn clone(&self) -> Self {
115        *self
116    }
117}
118impl<T> PartialEq for ReleasePolicy<T> {
119    fn eq(&self, other: &Self) -> bool {
120        self.collision == other.collision
121            && self.recovery_radius == other.recovery_radius
122            && self.sticky == other.sticky
123    }
124}
125
126impl<T> Default for ReleasePolicy<T> {
127    fn default() -> Self {
128        Self {
129            collision: CollisionDetector::default(),
130            recovery_radius: 48.0,
131            sticky: false,
132        }
133    }
134}
135
136impl<T> ReleasePolicy<T> {
137    pub fn strategy(strategy: CollisionStrategy) -> Self {
138        Self {
139            collision: CollisionDetector::BuiltIn(strategy),
140            ..Self::default()
141        }
142    }
143
144    pub fn with_recovery_radius(mut self, recovery_radius: f64) -> Self {
145        self.recovery_radius = recovery_radius.max(0.0);
146        self
147    }
148
149    pub fn with_collision(mut self, collision: CollisionDetector<T>) -> Self {
150        self.collision = collision;
151        self
152    }
153
154    pub fn with_sticky(mut self, sticky: bool) -> Self {
155        self.sticky = sticky;
156        self
157    }
158}
159
160pub fn rank_collisions<T: Clone + 'static>(
161    detector: CollisionDetector<T>,
162    request: CollisionRequest<T>,
163) -> Vec<Collision> {
164    match detector {
165        CollisionDetector::BuiltIn(strategy) => rank_builtin(strategy, &request),
166        CollisionDetector::Custom(callback) => {
167            let orders: Vec<_> = request
168                .candidates
169                .iter()
170                .map(|candidate| (candidate.id, candidate.order))
171                .collect();
172            let order = |zone| {
173                orders
174                    .iter()
175                    .find(|(candidate, _)| *candidate == zone)
176                    .map(|(_, order)| *order)
177                    .unwrap_or_default()
178            };
179            let mut ranked = callback.call(request);
180            ranked.sort_by(|a, b| {
181                a.score
182                    .total_cmp(&b.score)
183                    .then_with(|| order(b.zone).cmp(&order(a.zone)))
184            });
185            ranked
186        }
187    }
188}
189
190pub(crate) fn rank_builtin_candidates(
191    strategy: CollisionStrategy,
192    pointer: Point,
193    active_rect: Option<Rect>,
194    candidates: Vec<ZoneCandidate>,
195    max_distance: f64,
196) -> Vec<Collision> {
197    rank_builtin(
198        strategy,
199        &CollisionRequest {
200            pointer,
201            active_rect,
202            payload: (),
203            candidates,
204            max_distance,
205        },
206    )
207}
208
209fn rank_builtin<T>(strategy: CollisionStrategy, request: &CollisionRequest<T>) -> Vec<Collision> {
210    let mut ranked = Vec::new();
211    for candidate in &request.candidates {
212        let edge_distance = point_rect_distance(request.pointer, candidate.rect);
213        let overlap = request
214            .active_rect
215            .map(|active| intersection_area(active, candidate.rect))
216            .unwrap_or(0.0);
217        let eligible = match (strategy, request.active_rect) {
218            (CollisionStrategy::RectIntersection, Some(_)) => {
219                overlap > 0.0
220                    || (request.max_distance > 0.0 && edge_distance <= request.max_distance)
221            }
222            (CollisionStrategy::ClosestCenter | CollisionStrategy::ClosestCorners, Some(_)) => {
223                overlap > 0.0 || edge_distance <= request.max_distance
224            }
225            _ => edge_distance <= request.max_distance,
226        };
227        if !eligible {
228            continue;
229        }
230        let score = match strategy {
231            CollisionStrategy::PointerWithin => edge_distance,
232            CollisionStrategy::ClosestCenter => distance(
233                request
234                    .active_rect
235                    .map(|rect| rect.center())
236                    .unwrap_or(request.pointer),
237                candidate.rect.center(),
238            ),
239            CollisionStrategy::ClosestCorners => match request.active_rect {
240                Some(active) => closest_corner_distance(active, candidate.rect),
241                None => point_corner_distance(request.pointer, candidate.rect),
242            },
243            CollisionStrategy::RectIntersection => {
244                if overlap > 0.0 {
245                    -overlap
246                } else {
247                    edge_distance
248                }
249            }
250        };
251        ranked.push((
252            Collision {
253                zone: candidate.id,
254                score,
255            },
256            candidate.order,
257        ));
258    }
259    ranked.sort_by(|(a, ao), (b, bo)| {
260        a.score
261            .total_cmp(&b.score)
262            // Later registration wins exact ties, preserving the existing
263            // overlap contract.
264            .then_with(|| bo.cmp(ao))
265    });
266    ranked.into_iter().map(|(collision, _)| collision).collect()
267}
268
269fn distance(a: Point, b: Point) -> f64 {
270    let d = a - b;
271    (d.x * d.x + d.y * d.y).sqrt()
272}
273
274pub fn point_rect_distance(point: Point, rect: Rect) -> f64 {
275    let dx = (rect.x - point.x)
276        .max(point.x - (rect.x + rect.width))
277        .max(0.0);
278    let dy = (rect.y - point.y)
279        .max(point.y - (rect.y + rect.height))
280        .max(0.0);
281    (dx * dx + dy * dy).sqrt()
282}
283
284fn corners(rect: Rect) -> [Point; 4] {
285    [
286        Point::new(rect.x, rect.y),
287        Point::new(rect.x + rect.width, rect.y),
288        Point::new(rect.x + rect.width, rect.y + rect.height),
289        Point::new(rect.x, rect.y + rect.height),
290    ]
291}
292
293fn point_corner_distance(point: Point, rect: Rect) -> f64 {
294    corners(rect)
295        .into_iter()
296        .map(|corner| distance(point, corner))
297        .min_by(f64::total_cmp)
298        .unwrap_or(f64::INFINITY)
299}
300
301fn closest_corner_distance(a: Rect, b: Rect) -> f64 {
302    corners(a)
303        .into_iter()
304        .flat_map(|left| {
305            corners(b)
306                .into_iter()
307                .map(move |right| distance(left, right))
308        })
309        .min_by(f64::total_cmp)
310        .unwrap_or(f64::INFINITY)
311}
312
313fn intersection_area(a: Rect, b: Rect) -> f64 {
314    let width = (a.x + a.width).min(b.x + b.width) - a.x.max(b.x);
315    let height = (a.y + a.height).min(b.y + b.height) - a.y.max(b.y);
316    width.max(0.0) * height.max(0.0)
317}
318
319#[cfg(test)]
320mod tests {
321    use dioxus::prelude::*;
322
323    use super::*;
324
325    fn request(point: Point) -> CollisionRequest<()> {
326        CollisionRequest {
327            pointer: point,
328            active_rect: None,
329            payload: (),
330            candidates: vec![
331                ZoneCandidate {
332                    id: ZoneId(1),
333                    rect: Rect::new(0.0, 0.0, 100.0, 100.0),
334                    order: 0,
335                },
336                ZoneCandidate {
337                    id: ZoneId(2),
338                    rect: Rect::new(50.0, 0.0, 100.0, 100.0),
339                    order: 1,
340                },
341            ],
342            max_distance: 0.0,
343        }
344    }
345
346    #[test]
347    fn pointer_within_preserves_later_overlap_precedence() {
348        let ranked = rank_collisions(
349            CollisionDetector::BuiltIn(CollisionStrategy::PointerWithin),
350            request(Point::new(75.0, 50.0)),
351        );
352        assert_eq!(ranked[0].zone, ZoneId(2));
353    }
354
355    #[test]
356    fn recovery_radius_uses_distance_to_rect_edge() {
357        let mut request = request(Point::new(160.0, 50.0));
358        request.max_distance = 12.0;
359        let ranked = rank_collisions(CollisionDetector::default(), request);
360        assert_eq!(ranked[0].zone, ZoneId(2));
361    }
362
363    #[test]
364    fn rect_intersection_does_not_require_pointer_inside_target() {
365        let ranked = rank_collisions(
366            CollisionDetector::BuiltIn(CollisionStrategy::RectIntersection),
367            CollisionRequest {
368                pointer: Point::new(25.0, 50.0),
369                active_rect: Some(Rect::new(25.0, 25.0, 50.0, 50.0)),
370                payload: (),
371                candidates: vec![ZoneCandidate {
372                    id: ZoneId(1),
373                    rect: Rect::new(60.0, 25.0, 50.0, 50.0),
374                    order: 0,
375                }],
376                max_distance: 0.0,
377            },
378        );
379        assert_eq!(ranked[0].zone, ZoneId(1));
380    }
381
382    #[test]
383    fn rect_intersection_exact_hover_requires_shape_overlap() {
384        let ranked = rank_collisions(
385            CollisionDetector::BuiltIn(CollisionStrategy::RectIntersection),
386            CollisionRequest {
387                pointer: Point::new(75.0, 50.0),
388                active_rect: Some(Rect::new(0.0, 0.0, 20.0, 20.0)),
389                payload: (),
390                candidates: vec![ZoneCandidate {
391                    id: ZoneId(1),
392                    rect: Rect::new(50.0, 0.0, 100.0, 100.0),
393                    order: 0,
394                }],
395                max_distance: 0.0,
396            },
397        );
398        assert!(ranked.is_empty());
399    }
400
401    #[test]
402    fn closest_center_uses_the_active_shape_and_can_rank_pointer_outside() {
403        let ranked = rank_collisions(
404            CollisionDetector::BuiltIn(CollisionStrategy::ClosestCenter),
405            CollisionRequest {
406                pointer: Point::new(10.0, 10.0),
407                active_rect: Some(Rect::new(40.0, 0.0, 40.0, 40.0)),
408                payload: (),
409                candidates: vec![
410                    ZoneCandidate {
411                        id: ZoneId(1),
412                        rect: Rect::new(60.0, 0.0, 40.0, 40.0),
413                        order: 0,
414                    },
415                    ZoneCandidate {
416                        id: ZoneId(2),
417                        rect: Rect::new(500.0, 0.0, 40.0, 40.0),
418                        order: 1,
419                    },
420                ],
421                max_distance: 0.0,
422            },
423        );
424        assert_eq!(ranked[0].zone, ZoneId(1));
425    }
426
427    fn custom_ranking_probe() -> Element {
428        let ranked = rank_collisions(
429            CollisionDetector::Custom(Callback::new(|_| {
430                vec![
431                    Collision::new(ZoneId(1), 10.0),
432                    Collision::new(ZoneId(2), 1.0),
433                    Collision::new(ZoneId(3), 1.0),
434                ]
435            })),
436            CollisionRequest {
437                pointer: Point::default(),
438                active_rect: None,
439                payload: (),
440                candidates: vec![
441                    ZoneCandidate::new(ZoneId(1), Rect::default(), 0),
442                    ZoneCandidate::new(ZoneId(2), Rect::default(), 1),
443                    ZoneCandidate::new(ZoneId(3), Rect::default(), 2),
444                ],
445                max_distance: 0.0,
446            },
447        );
448        assert_eq!(
449            ranked
450                .iter()
451                .map(|collision| collision.zone)
452                .collect::<Vec<_>>(),
453            [ZoneId(3), ZoneId(2), ZoneId(1)]
454        );
455        rsx! {}
456    }
457
458    #[test]
459    fn custom_results_are_sorted_by_score_then_registration_order() {
460        let mut dom = VirtualDom::new(custom_ranking_probe);
461        dom.rebuild_in_place();
462    }
463}