Skip to main content

telar_ui_core/
reorder.rs

1//! Where a dragged item lands in a strip of items, and how to put it there.
2//!
3//! The whole of drag-to-reorder that is not the caller's: given the laid-out rects of the items in display
4//! order and where the pointer is, which slot is this, and what does the list look like once the item goes
5//! there. Everything above it — which strip, what a chip looks like, whether a drop may cross into another
6//! window — stays with the widget that owns those questions.
7//!
8//! The two rules here are the two that get written differently every time. A slot is decided by an item's
9//! **centre**, not by its edges, so passing the midpoint of a neighbour is what moves the gap rather than
10//! reaching its far edge. And a target slot counts positions in the list *before* the move, so moving an item
11//! rightwards has to account for the hole it leaves behind — the off-by-one that makes "drag one to the end"
12//! land one short.
13
14use geometry_core::Rect;
15
16/// Which way a strip runs. Not [`layout_core::Direction`], which is text direction (LTR/RTL) and answers a
17/// different question.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Axis {
20    Horizontal,
21    Vertical,
22}
23
24impl Axis {
25    /// The coordinate of `point` along this axis.
26    pub fn of(self, point: (f32, f32)) -> f32 {
27        match self {
28            Axis::Horizontal => point.0,
29            Axis::Vertical => point.1,
30        }
31    }
32
33    /// The centre of `rect` along this axis.
34    pub fn centre(self, rect: &Rect) -> f32 {
35        match self {
36            Axis::Horizontal => rect.x + rect.width / 2.0,
37            Axis::Vertical => rect.y + rect.height / 2.0,
38        }
39    }
40}
41
42/// The slot a pointer at `point` names, given the items' laid-out `rects` in display order: the number of
43/// items whose centre it has passed.
44///
45/// The result indexes the list *as displayed*, so it ranges over `0..=rects.len()` — `len()` meaning "past
46/// the last item". Feed it to [`apply_move`], which is what knows that a slot counted before the move is not
47/// the index the item ends up at.
48///
49/// `rects` must be in display order; a strip that lays out its items in a different order than it stores them
50/// has to permute before calling, since nothing here can tell the two apart.
51pub fn insertion_index(rects: &[Rect], point: (f32, f32), axis: Axis) -> usize {
52    let along = axis.of(point);
53    rects
54        .iter()
55        .filter(|rect| axis.centre(rect) < along)
56        .count()
57        .min(rects.len())
58}
59
60/// Moves the item at `from` into slot `to`, where `to` counts positions in `items` **as it is now** — the
61/// frame of reference [`insertion_index`] answers in. Returns whether anything moved.
62///
63/// Dropping an item onto the slot it already occupies (or the one immediately after it, which is the same
64/// place once the item is lifted out) is not a move, and reports as such so a caller can skip writing a
65/// signal nothing changed.
66pub fn apply_move<T>(items: &mut Vec<T>, from: usize, to: usize) -> bool {
67    if from >= items.len() {
68        return false;
69    }
70    let to = to.min(items.len());
71    // Removing `from` first shifts every later position down one, so a slot past it is one too far.
72    let target = if to > from { to - 1 } else { to };
73    if target == from {
74        return false;
75    }
76    let item = items.remove(from);
77    items.insert(target, item);
78    true
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn strip(count: usize) -> Vec<Rect> {
86        (0..count)
87            .map(|i| Rect {
88                x: i as f32 * 100.0,
89                y: 0.0,
90                width: 100.0,
91                height: 40.0,
92            })
93            .collect()
94    }
95
96    #[test]
97    fn a_slot_turns_at_an_items_centre_not_at_its_edge() {
98        let rects = strip(3);
99        assert_eq!(insertion_index(&rects, (49.0, 20.0), Axis::Horizontal), 0);
100        assert_eq!(insertion_index(&rects, (51.0, 20.0), Axis::Horizontal), 1);
101        assert_eq!(insertion_index(&rects, (151.0, 20.0), Axis::Horizontal), 2);
102    }
103
104    #[test]
105    fn past_the_last_item_is_the_slot_after_it() {
106        let rects = strip(3);
107        assert_eq!(insertion_index(&rects, (999.0, 20.0), Axis::Horizontal), 3);
108        assert_eq!(insertion_index(&rects, (-999.0, 20.0), Axis::Horizontal), 0);
109    }
110
111    #[test]
112    fn a_vertical_strip_reads_the_other_coordinate() {
113        let rects: Vec<Rect> = (0..3)
114            .map(|i| Rect {
115                x: 0.0,
116                y: i as f32 * 40.0,
117                width: 100.0,
118                height: 40.0,
119            })
120            .collect();
121        assert_eq!(insertion_index(&rects, (50.0, 21.0), Axis::Vertical), 1);
122        // The same point read along the other axis is a different answer, which is the point of the parameter.
123        assert_eq!(insertion_index(&rects, (50.0, 21.0), Axis::Horizontal), 0);
124    }
125
126    /// The off-by-one both hand-rolled versions had to solve: a slot counted before the item is lifted out.
127    #[test]
128    fn moving_rightwards_accounts_for_the_hole_left_behind() {
129        let mut items = vec!['a', 'b', 'c', 'd'];
130        // "past c" is slot 3 while `a` is still in the list; `a` must land between `c` and `d`.
131        assert!(apply_move(&mut items, 0, 3));
132        assert_eq!(items, vec!['b', 'c', 'a', 'd']);
133    }
134
135    #[test]
136    fn moving_leftwards_lands_on_the_slot_as_counted() {
137        let mut items = vec!['a', 'b', 'c', 'd'];
138        assert!(apply_move(&mut items, 3, 1));
139        assert_eq!(items, vec!['a', 'd', 'b', 'c']);
140    }
141
142    #[test]
143    fn dropping_where_it_already_is_moves_nothing() {
144        let mut items = vec!['a', 'b', 'c'];
145        assert!(!apply_move(&mut items, 1, 1));
146        // Slot 2 is the far side of `b` itself — still the same place once `b` is lifted out.
147        assert!(!apply_move(&mut items, 1, 2));
148        assert_eq!(items, vec!['a', 'b', 'c']);
149    }
150
151    #[test]
152    fn an_out_of_range_source_is_refused_rather_than_panicking() {
153        let mut items = vec!['a'];
154        assert!(!apply_move(&mut items, 5, 0));
155        assert_eq!(items, vec!['a']);
156    }
157}