widgetry/widgets/
drag_drop.rs

1use crate::{
2    Drawable, EventCtx, GeomBatch, GeomBatchStack, GfxCtx, Outcome, ScreenDims, ScreenPt,
3    ScreenRectangle, StackAlignment, StackAxis, Widget, WidgetImpl, WidgetOutput,
4};
5
6const SPACE_BETWEEN_CARDS: f64 = 2.0;
7
8pub struct DragDrop<T: Copy + PartialEq> {
9    label: String,
10    cards: Vec<Card<T>>,
11    draw: Drawable,
12    state: State,
13    axis: StackAxis,
14    dims: ScreenDims,
15    top_left: ScreenPt,
16}
17
18struct Card<T: PartialEq> {
19    value: T,
20    dims: ScreenDims,
21    default_batch: GeomBatch,
22    hovering_batch: GeomBatch,
23    selected_batch: GeomBatch,
24}
25
26#[derive(PartialEq)]
27enum State {
28    Initial {
29        hovering: Option<usize>,
30        selected: Option<usize>,
31    },
32    Idle {
33        hovering: Option<usize>,
34        selected: Option<usize>,
35    },
36    Dragging {
37        orig_idx: usize,
38        drag_from: ScreenPt,
39        cursor_at: ScreenPt,
40        new_idx: usize,
41    },
42}
43
44impl<T: 'static + Copy + PartialEq> DragDrop<T> {
45    /// This widget emits several events.
46    ///
47    /// - `Outcome::Changed(label)` when a different card is selected or hovered on
48    /// - `Outcome::Changed("dragging " + label)` while dragging, when the drop position of the
49    ///    card changes. Call `get_dragging_state` to learn the indices.
50    /// - `Outcome::DragDropReleased` when a card is dropped
51    ///
52    /// When you build a `Panel` containing one of these, you may need to call
53    /// `ignore_initial_events()`. If the cursor is hovering over a card when the panel is first
54    /// created, `Outcome::Changed` is immediately fired from this widget.
55    pub fn new(ctx: &EventCtx, label: &str, axis: StackAxis) -> Self {
56        DragDrop {
57            label: label.to_string(),
58            cards: vec![],
59            draw: Drawable::empty(ctx),
60            state: State::Idle {
61                hovering: None,
62                selected: None,
63            },
64            axis,
65            dims: ScreenDims::zero(),
66            top_left: ScreenPt::zero(),
67        }
68    }
69
70    pub fn into_widget(mut self, ctx: &EventCtx) -> Widget {
71        self.recalc_draw(ctx);
72        let label = self.label.clone();
73        Widget::new(Box::new(self)).named(label)
74    }
75
76    pub fn selected_value(&self) -> Option<T> {
77        let idx = match self.state {
78            State::Initial { selected, .. } | State::Idle { selected, .. } => selected,
79            State::Dragging { orig_idx, .. } => Some(orig_idx),
80        }?;
81
82        Some(self.cards[idx].value)
83    }
84
85    pub fn hovering_value(&self) -> Option<T> {
86        let idx = match self.state {
87            State::Initial { hovering, .. } | State::Idle { hovering, .. } => hovering,
88            _ => None,
89        }?;
90        Some(self.cards[idx].value)
91    }
92
93    pub fn push_card(
94        &mut self,
95        value: T,
96        dims: ScreenDims,
97        default_batch: GeomBatch,
98        hovering_batch: GeomBatch,
99        selected_batch: GeomBatch,
100    ) {
101        self.cards.push(Card {
102            value,
103            dims,
104            default_batch,
105            hovering_batch,
106            selected_batch,
107        });
108    }
109
110    pub fn set_initial_state(&mut self, selected_value: Option<T>, hovering_value: Option<T>) {
111        let selected = selected_value.and_then(|selected_value| {
112            self.cards
113                .iter()
114                .position(|card| card.value == selected_value)
115        });
116
117        let hovering = hovering_value.and_then(|hovering_value| {
118            self.cards
119                .iter()
120                .position(|card| card.value == hovering_value)
121        });
122
123        self.state = State::Initial { selected, hovering };
124    }
125
126    /// If a card is currently being dragged, return its original and (potential) new index.
127    pub fn get_dragging_state(&self) -> Option<(usize, usize)> {
128        match self.state {
129            State::Dragging {
130                orig_idx, new_idx, ..
131            } => Some((orig_idx, new_idx)),
132            _ => None,
133        }
134    }
135}
136
137impl<T: 'static + Copy + PartialEq> DragDrop<T> {
138    fn recalc_draw(&mut self, ctx: &EventCtx) {
139        let mut stack = GeomBatchStack::from_axis(Vec::new(), self.axis);
140        stack.set_spacing(SPACE_BETWEEN_CARDS);
141
142        // TODO: we could make alignment separately configurable, but these are the only
143        // combinations we currently use
144        stack.set_alignment(if self.axis == StackAxis::Vertical {
145            StackAlignment::Left
146        } else {
147            StackAlignment::Top
148        });
149
150        let (dims, batch) = match self.state {
151            State::Initial { hovering, selected } | State::Idle { hovering, selected } => {
152                for (idx, card) in self.cards.iter().enumerate() {
153                    if selected == Some(idx) {
154                        stack.push(card.selected_batch.clone());
155                    } else if hovering == Some(idx) {
156                        stack.push(card.hovering_batch.clone());
157                    } else {
158                        stack.push(card.default_batch.clone());
159                    }
160                }
161                let batch = stack.batch();
162                (batch.get_dims(), batch)
163            }
164            State::Dragging {
165                orig_idx,
166                drag_from,
167                cursor_at,
168                new_idx,
169            } => {
170                let orig_dims = self.cards[orig_idx].dims;
171
172                for (idx, card) in self.cards.iter().enumerate() {
173                    // the target we're dragging
174                    let batch = if idx == orig_idx {
175                        card.selected_batch.clone()
176                    } else if idx <= new_idx && idx > orig_idx {
177                        // move batch to the left or top if target is newly greater than us
178                        match self.axis {
179                            StackAxis::Horizontal => card
180                                .default_batch
181                                .clone()
182                                .translate(-(orig_dims.width + SPACE_BETWEEN_CARDS), 0.0),
183                            StackAxis::Vertical => card
184                                .default_batch
185                                .clone()
186                                .translate(0.0, -(orig_dims.height + SPACE_BETWEEN_CARDS)),
187                        }
188                    } else if idx >= new_idx && idx < orig_idx {
189                        // move batch to the right or bottom if target is newly less than us
190                        match self.axis {
191                            StackAxis::Horizontal => card
192                                .default_batch
193                                .clone()
194                                .translate(orig_dims.width + SPACE_BETWEEN_CARDS, 0.0),
195                            StackAxis::Vertical => card
196                                .default_batch
197                                .clone()
198                                .translate(0.0, orig_dims.height + SPACE_BETWEEN_CARDS),
199                        }
200                    } else {
201                        card.default_batch.clone()
202                    };
203
204                    stack.push(batch);
205                }
206
207                // PERF: avoid this clone by implementing a non-consuming `stack.get_dims()`.
208                // At the moment it seems like not a big deal to just clone the thing
209                let dims = stack.clone().batch().get_dims();
210
211                // The dragged batch follows the cursor, but don't translate it until we've captured
212                // the pre-existing `dims`, otherwise the dragged position will be included in the
213                // overall dims of this widget, causing other screen content to shift around as we
214                // drag.
215                let mut dragged_batch = std::mem::take(stack.get_mut(orig_idx).unwrap());
216
217                // offset the dragged item just a little to initially hint that it's moveable
218                let floating_effect_offset = 4.0;
219                dragged_batch = dragged_batch
220                    .translate(
221                        cursor_at.x - drag_from.x + floating_effect_offset,
222                        cursor_at.y - drag_from.y - floating_effect_offset,
223                    )
224                    .set_z_offset(-0.1);
225                *stack.get_mut(orig_idx).unwrap() = dragged_batch;
226
227                (dims, stack.batch())
228            }
229        };
230        self.dims = dims;
231        self.draw = batch.upload(ctx);
232    }
233
234    fn mouseover_card(&self, ctx: &EventCtx) -> Option<usize> {
235        let pt = ctx.canvas.get_cursor_in_screen_space()?;
236        let mut top_left = self.top_left;
237        for (idx, Card { dims, .. }) in self.cards.iter().enumerate() {
238            if ScreenRectangle::top_left(top_left, *dims).contains(pt) {
239                return Some(idx);
240            }
241            match self.axis {
242                StackAxis::Horizontal => {
243                    top_left.x += dims.width + SPACE_BETWEEN_CARDS;
244                }
245                StackAxis::Vertical => {
246                    top_left.y += dims.height + SPACE_BETWEEN_CARDS;
247                }
248            }
249        }
250        None
251    }
252}
253
254impl<T: 'static + Copy + PartialEq> WidgetImpl for DragDrop<T> {
255    fn get_dims(&self) -> ScreenDims {
256        self.dims
257    }
258
259    fn set_pos(&mut self, top_left: ScreenPt) {
260        self.top_left = top_left;
261    }
262
263    fn event(&mut self, ctx: &mut EventCtx, output: &mut WidgetOutput) {
264        let new_state = match self.state {
265            State::Initial { selected, hovering } => {
266                if let Some(idx) = self.mouseover_card(ctx) {
267                    if hovering != Some(idx) {
268                        output.outcome = Outcome::Changed(self.label.clone());
269                    }
270                    State::Idle {
271                        hovering: Some(idx),
272                        selected,
273                    }
274                } else {
275                    // Keep the initial state, which reflects hovering/selection from interacting
276                    // with the lanes on the map.
277                    return;
278                }
279            }
280            State::Idle { hovering, selected } => match self.mouseover_card(ctx) {
281                Some(idx) if ctx.input.left_mouse_button_pressed() => {
282                    let cursor = ctx.canvas.get_cursor_in_screen_space().unwrap();
283                    State::Dragging {
284                        orig_idx: idx,
285                        drag_from: cursor,
286                        cursor_at: cursor,
287                        new_idx: idx,
288                    }
289                }
290                maybe_idx => {
291                    if hovering != maybe_idx {
292                        output.outcome = Outcome::Changed(self.label.clone());
293                    }
294                    State::Idle {
295                        hovering: maybe_idx,
296                        selected,
297                    }
298                }
299            },
300            State::Dragging {
301                orig_idx,
302                new_idx,
303                cursor_at,
304                drag_from,
305            } => {
306                if ctx.input.left_mouse_button_released() {
307                    output.outcome =
308                        Outcome::DragDropReleased(self.label.clone(), orig_idx, new_idx);
309                    if orig_idx != new_idx {
310                        let item = self.cards.remove(orig_idx);
311                        self.cards.insert(new_idx, item);
312                    }
313
314                    State::Idle {
315                        hovering: Some(new_idx),
316                        selected: Some(new_idx),
317                    }
318                } else {
319                    // TODO https://jqueryui.com/sortable/ only swaps once you cross the center of
320                    // the new card
321                    let updated_idx = self.mouseover_card(ctx).unwrap_or(new_idx);
322                    if new_idx != updated_idx {
323                        output.outcome = Outcome::Changed(format!("dragging {}", self.label));
324                    }
325
326                    State::Dragging {
327                        orig_idx,
328                        new_idx: updated_idx,
329                        cursor_at: ctx.canvas.get_cursor_in_screen_space().unwrap_or(cursor_at),
330                        drag_from,
331                    }
332                }
333            }
334        };
335
336        if self.state != new_state {
337            self.state = new_state;
338            self.recalc_draw(ctx);
339        }
340
341        match self.state {
342            State::Initial {
343                hovering: Some(_), ..
344            }
345            | State::Idle {
346                hovering: Some(_), ..
347            } => ctx.cursor_grabbable(),
348            State::Dragging { .. } => {
349                ctx.cursor_grabbing();
350                if matches!(output.outcome, Outcome::Nothing) {
351                    output.outcome = Outcome::Focused(self.label.clone());
352                }
353            }
354            _ => {}
355        }
356    }
357
358    fn draw(&self, g: &mut GfxCtx) {
359        g.redraw_at(self.top_left, &self.draw);
360    }
361}