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}