Skip to main content

dioxus_flow/
settle.rs

1//! Turns transient rectangle geometry into a valid resting layout.
2//!
3//! Direct manipulation is deliberately unconstrained: a rectangle in a user's
4//! hand may pass through, or temporarily cover, anything. The boundary here is
5//! the other side of that gesture. Given the frames the gesture asked for, this
6//! module keeps the resting layout anchored and relocates the frames the gesture
7//! moved to their nearest clear landing.
8//!
9//! The application decides when a gesture has ended and which ids express its
10//! intent. This module knows only opaque ids, rectangles, a grid and a gap.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use crate::types::{Grid, Id, Point, Rect};
15
16/// Whether two rectangles come closer to one another than `gap`.
17///
18/// Exactly `gap` apart is clear rather than a clash: that is a valid resting
19/// position and the boundary at which layout settlement stops. This predicate
20/// deliberately does not constrain a gesture — something in the user's hand
21/// may overlap anything; [`settled`] uses it only when transient geometry
22/// becomes a resting layout.
23pub fn clash(a: Rect, b: Rect, gap: f64) -> bool {
24    a.x < b.x + b.width + gap
25        && b.x < a.x + a.width + gap
26        && a.y < b.y + b.height + gap
27        && b.y < a.y + a.height + gap
28}
29
30/// Settles `frames` into a layout in which no pair comes closer than `gap`.
31///
32/// `moving` names the frames the gesture just authored — the node under the
33/// pointer, the group travelling with it, or the node whose editor just grew.
34/// When one of those clashes with a resting frame, the moving frame gives way.
35/// A moving group that was clear internally stays rigid; malformed input in
36/// which two moving frames already clash is still repaired deterministically.
37///
38/// Clear frames are returned byte-for-byte unchanged. A displaced frame moves
39/// along one axis to the nearest clear grid position, and is tested against the
40/// complete current layout before it is accepted, so resolving one collision
41/// never creates another.
42pub fn settled(
43    frames: &BTreeMap<Id, Rect>,
44    moving: &BTreeSet<Id>,
45    gap: f64,
46    grid: Grid,
47) -> BTreeMap<Id, Rect> {
48    let gap = if gap.is_finite() { gap.max(0.0) } else { 0.0 };
49    let mut answer = frames.clone();
50
51    // A selection travelled as one shape in the user's hand, so it must also
52    // search for a landing as one shape. Translating the complete group keeps
53    // every internal distance unchanged while all frames that were already at
54    // rest remain anchors.
55    relocate_group(&mut answer, moving, gap, grid);
56
57    // Moving one frame clear of the complete current layout removes every
58    // collision involving it and cannot introduce one. There can therefore be
59    // at most one productive pass per frame; the extra pass observes completion.
60    for _ in 0..=frames.len() {
61        let Some((keeper, displaced)) = first_collision(&answer, moving, gap) else {
62            break;
63        };
64        let start = answer[&displaced];
65        let occupied: Vec<Rect> = answer
66            .iter()
67            .filter(|(id, _)| *id != &displaced)
68            .map(|(_, frame)| *frame)
69            .collect();
70        let next = nearest_clear(start, answer[&keeper], &occupied, gap, grid);
71        debug_assert_ne!(
72            next, start,
73            "a clashing frame must have somewhere to settle"
74        );
75        answer.insert(displaced, next);
76    }
77
78    debug_assert!(clear(&answer, gap), "layout settlement left an overlap");
79    answer
80}
81
82/// Relocates a clear moving group by one shared grid translation. The fallback
83/// loop in [`settled`] handles malformed groups that already overlap internally.
84fn relocate_group(frames: &mut BTreeMap<Id, Rect>, moving: &BTreeSet<Id>, gap: f64, grid: Grid) {
85    let group: Vec<Rect> = frames
86        .iter()
87        .filter(|(id, _)| moving.contains(*id))
88        .map(|(_, frame)| *frame)
89        .collect();
90    let fixed: Vec<Rect> = frames
91        .iter()
92        .filter(|(id, _)| !moving.contains(*id))
93        .map(|(_, frame)| *frame)
94        .collect();
95    if group.is_empty() || !clear_frames(&group, gap) || groups_clear(&group, &fixed, gap) {
96        return;
97    }
98    let Some(offset) = nearest_group_translation(&group, &fixed, gap, grid) else {
99        return;
100    };
101    debug_assert!(offset.x != 0.0 || offset.y != 0.0);
102    for (id, frame) in frames.iter_mut() {
103        if moving.contains(id) {
104            *frame = translated(*frame, offset);
105        }
106    }
107}
108
109/// The nearest one-axis translation that clears a moving group from every
110/// anchored frame. A candidate beyond the outermost anchor always exists.
111fn nearest_group_translation(
112    group: &[Rect],
113    fixed: &[Rect],
114    gap: f64,
115    grid: Grid,
116) -> Option<Point> {
117    let mut candidates = Vec::with_capacity(group.len() * fixed.len() * 4);
118    for frame in group {
119        for other in fixed {
120            candidates.extend([
121                Point::new(
122                    snap_before(other.x - frame.width - gap, grid) - frame.x,
123                    0.0,
124                ),
125                Point::new(snap_after(other.x + other.width + gap, grid) - frame.x, 0.0),
126                Point::new(
127                    0.0,
128                    snap_before(other.y - frame.height - gap, grid) - frame.y,
129                ),
130                Point::new(
131                    0.0,
132                    snap_after(other.y + other.height + gap, grid) - frame.y,
133                ),
134            ]);
135        }
136    }
137    candidates
138        .into_iter()
139        .filter(|offset| {
140            group.iter().all(|frame| {
141                fixed
142                    .iter()
143                    .all(|other| !clash(translated(*frame, *offset), *other, gap))
144            })
145        })
146        .min_by_key(|offset| translation_key(*offset))
147}
148
149fn translated(frame: Rect, offset: Point) -> Rect {
150    Rect::new(
151        frame.x + offset.x,
152        frame.y + offset.y,
153        frame.width,
154        frame.height,
155    )
156}
157
158fn groups_clear(group: &[Rect], fixed: &[Rect], gap: f64) -> bool {
159    group
160        .iter()
161        .all(|frame| fixed.iter().all(|other| !clash(*frame, *other, gap)))
162}
163
164fn clear_frames(frames: &[Rect], gap: f64) -> bool {
165    frames.iter().enumerate().all(|(index, frame)| {
166        frames[index + 1..]
167            .iter()
168            .all(|other| !clash(*frame, *other, gap))
169    })
170}
171
172/// The first collision in stable id order, expressed as the frame that keeps
173/// its place and the one that gives way.
174fn first_collision(
175    frames: &BTreeMap<Id, Rect>,
176    moving: &BTreeSet<Id>,
177    gap: f64,
178) -> Option<(Id, Id)> {
179    let entries: Vec<_> = frames.iter().collect();
180    for (index, (a_id, a)) in entries.iter().enumerate() {
181        for (b_id, b) in &entries[index + 1..] {
182            if !clash(**a, **b, gap) {
183                continue;
184            }
185            return Some(match (moving.contains(*a_id), moving.contains(*b_id)) {
186                (true, false) => ((*b_id).clone(), (*a_id).clone()),
187                (false, true) => ((*a_id).clone(), (*b_id).clone()),
188                // Equal mobility is resolved in stable id order. For the normal
189                // gesture case, moving frames began clear and never reach this
190                // branch against one another.
191                _ => ((*a_id).clone(), (*b_id).clone()),
192            });
193        }
194    }
195    None
196}
197
198/// The nearest one-axis landing that clears every occupied frame.
199fn nearest_clear(frame: Rect, keeper: Rect, occupied: &[Rect], gap: f64, grid: Grid) -> Rect {
200    let mut candidates = Vec::with_capacity(occupied.len() * 4);
201    for other in occupied {
202        candidates.extend([
203            Rect::new(
204                snap_before(other.x - frame.width - gap, grid),
205                grid.snap(frame.y),
206                frame.width,
207                frame.height,
208            ),
209            Rect::new(
210                snap_after(other.x + other.width + gap, grid),
211                grid.snap(frame.y),
212                frame.width,
213                frame.height,
214            ),
215            Rect::new(
216                grid.snap(frame.x),
217                snap_before(other.y - frame.height - gap, grid),
218                frame.width,
219                frame.height,
220            ),
221            Rect::new(
222                grid.snap(frame.x),
223                snap_after(other.y + other.height + gap, grid),
224                frame.width,
225                frame.height,
226            ),
227        ]);
228    }
229
230    let away_x = frame.center().x - keeper.center().x;
231    let away_y = frame.center().y - keeper.center().y;
232    candidates
233        .into_iter()
234        .filter(|candidate| occupied.iter().all(|other| !clash(*candidate, *other, gap)))
235        .min_by(|a, b| {
236            candidate_key(*a, frame, away_x, away_y)
237                .partial_cmp(&candidate_key(*b, frame, away_x, away_y))
238                .unwrap_or(std::cmp::Ordering::Equal)
239        })
240        // At least the candidate beyond the outermost occupied edge is clear.
241        // Keeping this total makes malformed, non-finite inputs no more harmful
242        // here than they already were when supplied.
243        .unwrap_or(frame)
244}
245
246/// Distance first; when two clear landings are equally near, prefer the side
247/// away from the frame that caused this one to give way, then a stable direction.
248fn candidate_key(
249    candidate: Rect,
250    start: Rect,
251    away_x: f64,
252    away_y: f64,
253) -> (Ordered, u8, u8, Ordered, Ordered) {
254    let dx = candidate.x - start.x;
255    let dy = candidate.y - start.y;
256    let dot = dx * away_x + dy * away_y;
257    let away_rank = if dot > 0.0 {
258        0
259    } else if dot == 0.0 {
260        1
261    } else {
262        2
263    };
264    let direction = if dx > 0.0 {
265        0 // right
266    } else if dy > 0.0 {
267        1 // down
268    } else if dx < 0.0 {
269        2 // left
270    } else {
271        3 // up
272    };
273    (
274        Ordered(dx.abs() + dy.abs()),
275        away_rank,
276        direction,
277        Ordered(candidate.x),
278        Ordered(candidate.y),
279    )
280}
281
282fn translation_key(offset: Point) -> (Ordered, u8, Ordered, Ordered) {
283    let direction = if offset.x > 0.0 {
284        0 // right
285    } else if offset.y > 0.0 {
286        1 // down
287    } else if offset.x < 0.0 {
288        2 // left
289    } else {
290        3 // up
291    };
292    (
293        Ordered(offset.x.abs() + offset.y.abs()),
294        direction,
295        Ordered(offset.x),
296        Ordered(offset.y),
297    )
298}
299
300/// A tiny total-order wrapper for tuple comparison without making geometry's
301/// public scalar type anything other than `f64`.
302#[derive(Clone, Copy, Debug, PartialEq)]
303struct Ordered(f64);
304
305impl Eq for Ordered {}
306
307impl PartialOrd for Ordered {
308    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
309        Some(self.cmp(other))
310    }
311}
312
313impl Ord for Ordered {
314    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
315        self.0.total_cmp(&other.0)
316    }
317}
318
319fn snap_before(value: f64, grid: Grid) -> f64 {
320    (value / grid.size()).floor() * grid.size()
321}
322
323fn snap_after(value: f64, grid: Grid) -> f64 {
324    (value / grid.size()).ceil() * grid.size()
325}
326
327fn clear(frames: &BTreeMap<Id, Rect>, gap: f64) -> bool {
328    clear_frames(&frames.values().copied().collect::<Vec<_>>(), gap)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    const GRID: Grid = Grid::new(12.0);
336    const GAP: f64 = 12.0;
337
338    fn card(x: f64, y: f64) -> Rect {
339        Rect::new(x, y, 216.0, 48.0)
340    }
341
342    fn map(entries: &[(&str, Rect)]) -> BTreeMap<Id, Rect> {
343        entries
344            .iter()
345            .map(|(id, frame)| ((*id).into(), *frame))
346            .collect()
347    }
348
349    #[test]
350    fn a_gap_apart_is_clear_and_anything_closer_is_not() {
351        let a = card(0.0, 0.0);
352        assert!(!clash(a, Rect::new(228.0, 0.0, 216.0, 48.0), GAP));
353        assert!(clash(a, Rect::new(227.0, 0.0, 216.0, 48.0), GAP));
354    }
355
356    #[test]
357    fn separation_on_either_axis_is_enough() {
358        let a = card(0.0, 0.0);
359        assert!(!clash(a, Rect::new(0.0, 60.0, 216.0, 48.0), GAP));
360        assert!(!clash(a, Rect::new(-228.0, 0.0, 216.0, 48.0), GAP));
361    }
362
363    #[test]
364    fn a_clear_layout_is_returned_unchanged() {
365        let frames = map(&[("a", card(0.0, 0.0)), ("b", card(240.0, 0.0))]);
366        assert_eq!(settled(&frames, &BTreeSet::new(), GAP, GRID), frames);
367    }
368
369    #[test]
370    fn the_dragged_frame_moves_and_the_resting_frame_stays_anchored() {
371        let frames = map(&[
372            ("dragged", card(240.0, 0.0)),
373            ("resting", card(240.0, 0.0)),
374            ("unrelated", card(1200.0, 0.0)),
375        ]);
376        let answer = settled(&frames, &BTreeSet::from(["dragged".into()]), GAP, GRID);
377
378        assert_eq!(answer["dragged"], card(240.0, 60.0));
379        assert_eq!(answer["resting"], frames["resting"]);
380        assert_eq!(answer["unrelated"], frames["unrelated"]);
381        assert!(clear(&answer, GAP));
382    }
383
384    #[test]
385    fn a_dragged_group_moves_rigidly_while_its_obstacle_stays_anchored() {
386        let frames = map(&[
387            ("a", card(0.0, 0.0)),
388            ("b", card(0.0, 60.0)),
389            ("obstacle", card(0.0, 60.0)),
390        ]);
391        let moving = BTreeSet::from(["a".into(), "b".into()]);
392        let answer = settled(&frames, &moving, GAP, GRID);
393
394        let a_offset = Point::new(answer["a"].x - frames["a"].x, answer["a"].y - frames["a"].y);
395        let b_offset = Point::new(answer["b"].x - frames["b"].x, answer["b"].y - frames["b"].y);
396        assert_ne!(a_offset, Point::default());
397        assert_eq!(a_offset, b_offset);
398        assert_eq!(answer["obstacle"], frames["obstacle"]);
399        assert!(clear(&answer, GAP));
400    }
401
402    #[test]
403    fn several_overlaps_are_all_repaired_on_the_grid() {
404        let frames = map(&[
405            ("a", card(0.0, 0.0)),
406            ("b", card(0.0, 0.0)),
407            ("c", card(0.0, 0.0)),
408            ("d", card(0.0, 0.0)),
409        ]);
410        let answer = settled(&frames, &BTreeSet::from(["a".into()]), GAP, GRID);
411
412        assert!(clear(&answer, GAP));
413        for frame in answer.values() {
414            assert_eq!(GRID.snap(frame.x), frame.x);
415            assert_eq!(GRID.snap(frame.y), frame.y);
416        }
417    }
418
419    #[test]
420    fn a_grown_node_relocates_while_the_node_below_stays_anchored() {
421        let grown = Rect::new(0.0, 0.0, 216.0, 120.0);
422        let below = card(0.0, 60.0);
423        let frames = map(&[("grown", grown), ("below", below)]);
424        let answer = settled(&frames, &BTreeSet::from(["grown".into()]), GAP, GRID);
425
426        assert_ne!(answer["grown"], grown);
427        assert_eq!(answer["below"], below);
428        assert!(clear(&answer, GAP));
429    }
430}