hcegui/
dnd.rs

1//! Flexible API for drag-and-drop and reordering.
2//!
3//! - Any UI widget or layout can be made draggable
4//! - Any UI widget or layout can be given a handle for dragging
5//! - Any UI widget or layout can be made a target for dragging
6//! - Any UI widget or layout can be made a target for reordering
7//! - Multiple separate drag-and-drop environments can coexist and even overlap
8//!   in the same UI
9//!
10//! # Examples
11//!
12//! ```
13//! # egui::__run_test_ui(|ui| {
14//! use hcegui::*;
15//!
16//! let mut elements = vec!["point", "line", "plane", "space"];
17//! let mut dnd = reorder::Dnd::new(ui.ctx(), ui.next_auto_id());
18//! for (i, &elem) in elements.iter().enumerate() {
19//!     dnd.reorderable_with_handle(ui, i, |ui, _| ui.label(elem));
20//! }
21//! if let Some(r) = dnd.finish(ui).if_done_dragging() {
22//!     r.reorder_vec(&mut elements);
23//! }
24//! # });
25//! ```
26//!
27//! For more advanced examples, see
28//! [`bin/demo/reorder.rs`](https://github.com/HactarCE/hcegui/blob/main/src/bin/demo/reorder.rs).
29
30use std::hash::Hash;
31
32/// Whether the payload should be placed before or after the target.
33#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
34#[allow(missing_docs)]
35pub enum BeforeOrAfter {
36    Before,
37    After,
38}
39
40/// Styling for [`Dnd`].
41#[derive(Debug, Copy, Clone)]
42pub struct DndStyle {
43    /// Rounding of hole left behind by the payload.
44    pub payload_hole_rounding: f32,
45    /// Opacity of background in the hole left behind by the payload.
46    pub payload_hole_opacity: f32,
47    /// Opacity of dragged payload.
48    pub payload_opacity: f32,
49    /// Width of non-reorder drop zone stroke.
50    pub drop_zone_stroke_width: f32,
51    /// Rounding of non-reorder drop zones.
52    pub drop_zone_rounding: f32,
53    /// Width of reorder drop zone line stroke.
54    pub reorder_stroke_width: f32,
55}
56impl Default for DndStyle {
57    fn default() -> Self {
58        Self {
59            payload_hole_rounding: 3.0,
60            payload_hole_opacity: 0.25,
61            payload_opacity: 1.0,
62            drop_zone_stroke_width: 2.0,
63            drop_zone_rounding: 3.0,
64            reorder_stroke_width: 2.0,
65        }
66    }
67}
68
69/// Drag-and-drop environment.
70///
71/// - `Payload` is a type that identifies the things being dragged.
72/// - `Target` is a type that indentifies the drop zones.
73///
74/// For reordering a list with `usize` indices, use [`ReorderDnd`].
75///
76/// Note that you **must** call either [`Dnd::finish()`] or
77/// [`Dnd::allow_unfinished()`] before the `Dnd` goes out of scope.
78#[derive(Debug)]
79pub struct Dnd<Payload, Target> {
80    ctx: egui::Context,
81
82    /// ID used to store state.
83    id: egui::Id,
84    /// Styling
85    pub style: DndStyle,
86    /// State persisted between frames.
87    current_drag: Option<DndDragState>,
88    /// Payload value being dragged.
89    payload: Option<Payload>,
90    /// Target where the payload is being hovered.
91    target: Option<Target>,
92    /// Locations where the payload can be dropped for reordering.
93    reorder_drop_zones: Vec<ReorderTarget<Target>>,
94}
95impl<Payload, Target> Dnd<Payload, Target> {
96    /// Constructs a new drag-and-drop context.
97    #[track_caller]
98    pub fn new(ctx: &egui::Context, id: impl Into<egui::Id>) -> Self {
99        let id = id.into();
100
101        let (last_frame_was_unfinished, state) = ctx.data_mut(|data| {
102            let last_frame_was_unfinished = data.remove_temp::<()>(id).is_some();
103            data.insert_temp(id, ()); // marker that `finish()` has not been called yet
104            let state = data.remove_temp::<DndDragState>(id);
105            (last_frame_was_unfinished, state)
106        });
107        assert!(
108            !last_frame_was_unfinished,
109            "Dnd dropped without calling `finish()`. Call `allow_unfinished()` if this is intentional.",
110        );
111
112        let mut this = Self {
113            ctx: ctx.clone(),
114
115            id,
116            style: DndStyle::default(),
117            current_drag: state,
118            payload: None,
119            target: None,
120            reorder_drop_zones: vec![],
121        };
122
123        ctx.input(|input| {
124            if !(input.pointer.any_down() || input.pointer.any_released()) {
125                // Done dragging -> delete payload
126                this.current_drag = None;
127            }
128        });
129
130        this
131    }
132
133    /// Overrides the style.
134    #[must_use]
135    pub fn with_style(mut self, style: DndStyle) -> Self {
136        self.style = style;
137        self
138    }
139
140    /// Returns whether there is an active drag in this context.
141    pub fn is_dragging(&self) -> bool {
142        self.current_drag.is_some()
143    }
144    /// Returns the ID of the payload being dragged, if there is one.
145    pub fn payload_id(&self) -> Option<egui::Id> {
146        self.current_drag.as_ref().map(|state| state.payload_id)
147    }
148
149    /// Allows the `Dnd` to be dropped without calling `finish()`.
150    ///
151    /// By default in debug mode, the thread will panic if a `Dnd` is dropped
152    /// without calling `finish()`. (Actually the panic happens on the next
153    /// frame when the `Dnd` is created again, since panicking in a destructor
154    /// is rude.)
155    #[must_use]
156    pub fn allow_unfinished(self) -> Self {
157        self.ctx.data_mut(|data| data.remove_temp::<()>(self.id)); // safe to call multiple times
158        self
159    }
160
161    /// Adds a new draggable object with a custom ID. See [`Dnd::draggable()`].
162    pub fn draggable_with_id<R>(
163        &mut self,
164        ui: &mut egui::Ui,
165        id: egui::Id,
166        payload: Payload,
167        add_contents: impl FnOnce(&mut egui::Ui) -> (egui::Response, R),
168    ) -> egui::InnerResponse<R> {
169        let state = self
170            .current_drag
171            .as_mut()
172            .filter(|state| state.payload_id == id);
173
174        if ui.is_sizing_pass() {
175            ui.scope(|ui| add_contents(ui).1)
176        } else if let Some(state) = state {
177            ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
178            self.payload = Some(payload);
179
180            // Paint the widget to a different layer so that we can move it
181            // around independently. Highlight the widget so that it looks like
182            // it's still being hovered.
183            let layer_id = egui::LayerId::new(egui::Order::Tooltip, id);
184            let r = ui.scope_builder(egui::UiBuilder::new().layer_id(layer_id), |ui| {
185                ui.set_opacity(self.style.payload_opacity);
186                // `push_id()` is a workaround for https://github.com/emilk/egui/issues/2253
187                ui.push_id(id, |ui| add_contents(ui)).inner
188            });
189            let (_, return_value) = r.inner;
190
191            ui.painter().rect_filled(
192                r.response.rect,
193                self.style.payload_hole_rounding,
194                (ui.visuals().widgets.hovered.bg_fill)
195                    .gamma_multiply(self.style.payload_hole_opacity),
196            );
197
198            if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
199                let delta = pointer_pos + state.cursor_offset - r.response.rect.left_top();
200                ui.ctx().transform_layer_shapes(
201                    layer_id,
202                    egui::emath::TSTransform::from_translation(delta),
203                );
204                state.drop_pos = r.response.rect.center() + delta;
205            }
206
207            egui::InnerResponse::new(return_value, r.response)
208        } else {
209            // We must use `.scope()` *and* `.push_id()` so that the IDs are all
210            // the same as the other case.
211            let r = ui.scope(|ui| ui.push_id(id, |ui| add_contents(ui)).inner);
212            let (drag_handle_response, return_value) = r.inner;
213
214            // Ensure that the drag handle detects drags
215            let drag_handle_response = drag_handle_response.interact(egui::Sense::drag());
216
217            if !drag_handle_response.sense.senses_click() && drag_handle_response.hovered() {
218                ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
219            }
220
221            if drag_handle_response.drag_started()
222                && let Some(interact_pos) = drag_handle_response.interact_pointer_pos()
223            {
224                let cursor_offset = r.response.rect.left_top() - interact_pos;
225                self.current_drag = Some(DndDragState {
226                    payload_id: id,
227                    cursor_offset,
228                    drop_pos: r.response.rect.center(),
229                });
230                self.payload = Some(payload);
231            }
232
233            egui::InnerResponse::new(return_value, r.response)
234        }
235    }
236
237    /// Adds a new draggable object, using `payload` for the ID.
238    ///
239    /// `add_contents` takes the [`egui::Ui`] and the ID of the current
240    /// draggable element. If it is equal to [`Dnd::payload_id()`], then the
241    /// current element is being dragged.
242    ///
243    /// The first value returned by `add_contents` is used as the response for
244    /// the drag handle, which may be any widget or region that does not use
245    /// drags for other interaction.
246    pub fn draggable<R>(
247        &mut self,
248        ui: &mut egui::Ui,
249        payload: Payload,
250        add_contents: impl FnOnce(&mut egui::Ui, egui::Id) -> (egui::Response, R),
251    ) -> egui::InnerResponse<R>
252    where
253        Payload: Hash,
254    {
255        let id = self.id.with(&payload);
256        self.draggable_with_id(ui, id, payload, |ui| add_contents(ui, id))
257    }
258
259    /// Add a drop zone onto an existing widget.
260    ///
261    /// `target` is a value representing this drop zone.
262    pub fn drop_zone(&mut self, ui: &mut egui::Ui, r: &egui::Response, target: Target) {
263        if ui.is_sizing_pass() {
264            return;
265        }
266
267        if !self.is_dragging() {
268            return;
269        }
270
271        let color = ui.visuals().widgets.active.bg_stroke.color;
272        let width = self.style.drop_zone_stroke_width;
273        let active_stroke = egui::Stroke { width, color };
274
275        let color = ui.visuals().widgets.noninteractive.bg_stroke.color;
276        let inactive_stroke = egui::Stroke { width, color };
277
278        let is_active = self
279            .current_drag
280            .as_ref()
281            .is_some_and(|s| r.interact_rect.contains(s.drop_pos));
282
283        if is_active {
284            self.target = Some(target);
285        }
286
287        let stroke = if is_active {
288            active_stroke
289        } else {
290            inactive_stroke
291        };
292
293        ui.painter().rect_stroke(
294            r.rect,
295            self.style.drop_zone_rounding,
296            stroke,
297            egui::StrokeKind::Outside,
298        );
299    }
300
301    /// Ends the drag-and-drop context and returns a response.
302    pub fn finish(mut self, ui: &egui::Ui) -> DndResponse<Payload, Target> {
303        self = self.allow_unfinished();
304
305        // If nothing is being dragged, do nothing
306        let Some(state) = self.current_drag.take() else {
307            return DndResponse::Inactive;
308        };
309        let Some(payload) = self.payload.take() else {
310            return DndResponse::Inactive;
311        };
312
313        // Compute reorder drop target and draw line
314        let reorder_drop_target = (|| {
315            let cursor_pos = ui.input(|input| input.pointer.interact_pos())?;
316            let drop_pos = state.drop_pos;
317
318            let clip_rect = &ui.clip_rect();
319            if !clip_rect.contains(egui::pos2(drop_pos.x, cursor_pos.y))
320                && !clip_rect.contains(egui::pos2(cursor_pos.x, drop_pos.y))
321            {
322                return None; // cursor position is outside the current UI
323            }
324
325            let closest = std::mem::take(&mut self.reorder_drop_zones)
326                .into_iter()
327                .filter_map(|drop_zone| {
328                    let [a, b] = drop_zone.line_endpoints;
329                    let distance_to_cursor = if drop_zone.direction.is_horizontal() {
330                        (a.y..=b.y)
331                            .contains(&drop_pos.y)
332                            .then(|| (a.x - cursor_pos.x).abs())
333                    } else {
334                        (a.x..=b.x)
335                            .contains(&drop_pos.x)
336                            .then(|| (a.y - cursor_pos.y).abs())
337                    };
338                    Some((drop_zone, distance_to_cursor?))
339                })
340                .min_by(|(_, distance1), (_, distance2)| f32::total_cmp(distance1, distance2));
341
342            closest.map(|(drop_zone, _distance)| {
343                let color = ui.visuals().widgets.active.bg_stroke.color;
344                let stroke = egui::Stroke::new(self.style.reorder_stroke_width, color);
345                ui.painter()
346                    .with_clip_rect(drop_zone.clip_rect.expand(self.style.reorder_stroke_width))
347                    .line_segment(drop_zone.line_endpoints, stroke);
348                drop_zone.target
349            })
350        })();
351        if self.target.is_none() {
352            // IIFE to mimic try_block
353            self.target = reorder_drop_target;
354        }
355
356        // Compute response and store state
357        if self.ctx.input(|input| input.pointer.any_released()) {
358            if let Some(target) = self.target.take() {
359                // done dragging
360                DndResponse::DoneDragging(DndMove { payload, target })
361            } else {
362                // done dragging but not hovering any endpoint
363                DndResponse::Inactive
364            }
365        } else {
366            // still dragging
367            self.ctx
368                .data_mut(|data| data.insert_temp::<DndDragState>(self.id, state));
369            let target = self.target.take();
370            DndResponse::MidDrag(DndMove { payload, target })
371        }
372    }
373
374    /// Adds a new reorder drop zone at `ui.cursor()`.
375    pub fn reorder_drop_zone(&mut self, ui: &mut egui::Ui, target: Target) {
376        let dir = ui.layout().main_dir;
377        let rect = ui.cursor();
378        self.reorder_drop_zones.push(ReorderTarget {
379            line_endpoints: match dir {
380                egui::Direction::LeftToRight => [rect.left_top(), rect.left_bottom()],
381                egui::Direction::RightToLeft => [rect.right_top(), rect.right_bottom()],
382                egui::Direction::TopDown => [rect.left_top(), rect.right_top()],
383                egui::Direction::BottomUp => [rect.left_bottom(), rect.right_bottom()],
384            },
385            clip_rect: ui.clip_rect(),
386            direction: dir,
387            target,
388        });
389    }
390}
391
392impl<Payload, Target: Clone, BA: From<BeforeOrAfter>> Dnd<Payload, (Target, BA)> {
393    /// Creates a new reorder drop zone before and after `r`.
394    pub fn reorder_drop_zone_before_after(
395        &mut self,
396        ui: &mut egui::Ui,
397        r: &egui::Response,
398        target: Target,
399    ) {
400        if !self.is_dragging() {
401            return;
402        }
403
404        let expansion = ui.spacing().item_spacing / 2.0;
405        let rect = r.rect.expand2(expansion);
406        let clip_rect = ui.clip_rect().expand2(expansion);
407
408        let dir = ui.layout().main_dir;
409        let tl = rect.left_top();
410        let tr = rect.right_top();
411        let dl = rect.left_bottom();
412        let dr = rect.right_bottom();
413        self.reorder_drop_zones.push(ReorderTarget {
414            line_endpoints: [tl, if dir.is_horizontal() { dl } else { tr }],
415            clip_rect,
416            direction: dir,
417            target: (target.clone(), BeforeOrAfter::Before.into()),
418        });
419        self.reorder_drop_zones.push(ReorderTarget {
420            line_endpoints: [if dir.is_horizontal() { tr } else { dl }, dr],
421            clip_rect,
422            direction: dir,
423            target: (target, BeforeOrAfter::After.into()),
424        });
425    }
426}
427
428impl<I: Clone + PartialEq + Hash> Dnd<I, (I, BeforeOrAfter)> {
429    /// Adds a new draggable object, using `index` for the ID. See
430    /// [`Dnd::draggable()`].
431    pub fn reorderable<R>(
432        &mut self,
433        ui: &mut egui::Ui,
434        index: I,
435        add_contents: impl FnOnce(&mut egui::Ui, egui::Id) -> (egui::Response, R),
436    ) -> egui::InnerResponse<R> {
437        let r = self.draggable(ui, index.clone(), add_contents);
438        self.reorder_drop_zone_before_after(ui, &r.response, index);
439        r
440    }
441
442    /// Adds a new object with a draggable handle, using `index` for the ID. See
443    /// [`Dnd::draggable()`].
444    pub fn reorderable_with_handle<R>(
445        &mut self,
446        ui: &mut egui::Ui,
447        index: I,
448        add_contents: impl FnOnce(&mut egui::Ui, egui::Id) -> R,
449    ) -> egui::InnerResponse<R> {
450        self.reorderable(ui, index, |ui, id| {
451            let main_dir = ui.layout().main_dir();
452            ui.horizontal(|ui| {
453                if main_dir.is_vertical() {
454                    ui.set_width(ui.available_width());
455                }
456                (ui.add(ReorderHandle), add_contents(ui, id))
457            })
458            .inner
459        })
460    }
461}
462
463/// State persisted between frames for each [`Dnd`].
464#[derive(Debug, Clone)]
465struct DndDragState {
466    payload_id: egui::Id,
467    cursor_offset: egui::Vec2,
468    drop_pos: egui::Pos2,
469}
470impl Default for DndDragState {
471    /// This value is never actually used, but the trait impl is necessary for
472    /// [`egui::Data::remove_temp()`].
473    fn default() -> Self {
474        Self {
475            payload_id: egui::Id::NULL,
476            cursor_offset: Default::default(),
477            drop_pos: Default::default(),
478        }
479    }
480}
481
482#[derive(Debug)]
483struct ReorderTarget<Target> {
484    line_endpoints: [egui::Pos2; 2],
485    clip_rect: egui::Rect,
486    direction: egui::Direction,
487    target: Target,
488}
489
490/// Response from a drag-and-drop.
491#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
492pub enum DndResponse<Payload, Target> {
493    /// Not dragging.
494    #[default]
495    Inactive,
496    /// In the middle of a drag-and-drop.
497    MidDrag(DndMove<Payload, Option<Target>>),
498    /// Just completed a drag-and-drop.
499    DoneDragging(DndMove<Payload, Target>),
500}
501impl<Payload, Target> DndResponse<Payload, Target> {
502    /// Returns the drag-and-drop response only on the frame the payload was
503    /// dropped.
504    pub fn if_done_dragging(self) -> Option<DndMove<Payload, Target>> {
505        match self {
506            DndResponse::DoneDragging(dnd_response) => Some(dnd_response),
507            _ => None,
508        }
509    }
510}
511
512/// Drag-and-drop for reordering a sequence.
513pub type ReorderDnd<I = usize> = Dnd<I, (I, BeforeOrAfter)>;
514
515/// Drag-and-drop move.
516#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
517pub struct DndMove<Payload, Target> {
518    /// Thing being moved.
519    pub payload: Payload,
520    /// Place the payload was moved to.
521    pub target: Target,
522}
523impl<Payload, Target> DndMove<Payload, Target> {
524    /// Constructs a drag-and-drop response.
525    pub fn new(payload: Payload, target: Target) -> Self {
526        Self { payload, target }
527    }
528}
529
530/// Drag-and-drop move for reordering a sequence.
531pub type ReorderDndMove<I = usize> = DndMove<I, (I, BeforeOrAfter)>;
532impl ReorderDndMove {
533    /// Returns the `i` and `j` such that the element at index `i` should shift
534    /// to index `j`.
535    pub fn list_reorder_indices(self) -> (usize, usize) {
536        let i = self.payload;
537        let (j, before_or_after) = self.target;
538        // Overflow/underflow is impossible because we only add/subtract 1 when `i` and
539        // `j` are
540        match (j.cmp(&i), before_or_after) {
541            (std::cmp::Ordering::Greater, BeforeOrAfter::Before) => (i, j - 1),
542            (std::cmp::Ordering::Less, BeforeOrAfter::After) => (i, j + 1),
543            _ => (i, j),
544        }
545    }
546
547    /// Reorders a slice.
548    pub fn reorder<T>(self, v: &mut [T]) {
549        let (i, j) = self.list_reorder_indices();
550        if i < j {
551            v[i..=j].rotate_left(1);
552        } else {
553            v[j..=i].rotate_right(1);
554        }
555    }
556}
557
558/// Visual handle for dragging widgets.
559pub struct ReorderHandle;
560impl egui::Widget for ReorderHandle {
561    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
562        let (rect, r) = ui.allocate_exact_size(egui::vec2(12.0, 20.0), egui::Sense::drag());
563        if ui.is_rect_visible(rect) {
564            // Change color based on hover/focus.
565            let color = if r.has_focus() || r.dragged() {
566                ui.visuals().strong_text_color()
567            } else if r.hovered() {
568                ui.visuals().text_color()
569            } else {
570                ui.visuals().weak_text_color()
571            };
572
573            // Draw 6 dots.
574            let r = ui.spacing().button_padding.x / 2.0;
575            for dy in [-2.0, 0.0, 2.0] {
576                for dx in [-1.0, 1.0] {
577                    const RADIUS: f32 = 1.0;
578                    let pos = rect.center() + egui::vec2(dx, dy) * r;
579                    ui.painter().circle_filled(pos, RADIUS, color);
580                }
581            }
582        }
583        r
584    }
585}