1use std::hash::Hash;
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
34#[allow(missing_docs)]
35pub enum BeforeOrAfter {
36 Before,
37 After,
38}
39
40#[derive(Debug, Copy, Clone)]
42pub struct DndStyle {
43 pub payload_hole_rounding: f32,
45 pub payload_hole_opacity: f32,
47 pub payload_opacity: f32,
49 pub drop_zone_stroke_width: f32,
51 pub drop_zone_rounding: f32,
53 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#[derive(Debug)]
79pub struct Dnd<Payload, Target> {
80 ctx: egui::Context,
81
82 id: egui::Id,
84 pub style: DndStyle,
86 current_drag: Option<DndDragState>,
88 payload: Option<Payload>,
90 target: Option<Target>,
92 reorder_drop_zones: Vec<ReorderTarget<Target>>,
94}
95impl<Payload, Target> Dnd<Payload, Target> {
96 #[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, ()); 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 this.current_drag = None;
127 }
128 });
129
130 this
131 }
132
133 #[must_use]
135 pub fn with_style(mut self, style: DndStyle) -> Self {
136 self.style = style;
137 self
138 }
139
140 pub fn is_dragging(&self) -> bool {
142 self.current_drag.is_some()
143 }
144 pub fn payload_id(&self) -> Option<egui::Id> {
146 self.current_drag.as_ref().map(|state| state.payload_id)
147 }
148
149 #[must_use]
156 pub fn allow_unfinished(self) -> Self {
157 self.ctx.data_mut(|data| data.remove_temp::<()>(self.id)); self
159 }
160
161 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 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 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 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 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 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 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 pub fn finish(mut self, ui: &egui::Ui) -> DndResponse<Payload, Target> {
303 self = self.allow_unfinished();
304
305 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 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; }
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 self.target = reorder_drop_target;
354 }
355
356 if self.ctx.input(|input| input.pointer.any_released()) {
358 if let Some(target) = self.target.take() {
359 DndResponse::DoneDragging(DndMove { payload, target })
361 } else {
362 DndResponse::Inactive
364 }
365 } else {
366 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 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 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 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 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#[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 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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
492pub enum DndResponse<Payload, Target> {
493 #[default]
495 Inactive,
496 MidDrag(DndMove<Payload, Option<Target>>),
498 DoneDragging(DndMove<Payload, Target>),
500}
501impl<Payload, Target> DndResponse<Payload, Target> {
502 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
512pub type ReorderDnd<I = usize> = Dnd<I, (I, BeforeOrAfter)>;
514
515#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
517pub struct DndMove<Payload, Target> {
518 pub payload: Payload,
520 pub target: Target,
522}
523impl<Payload, Target> DndMove<Payload, Target> {
524 pub fn new(payload: Payload, target: Target) -> Self {
526 Self { payload, target }
527 }
528}
529
530pub type ReorderDndMove<I = usize> = DndMove<I, (I, BeforeOrAfter)>;
532impl ReorderDndMove {
533 pub fn list_reorder_indices(self) -> (usize, usize) {
536 let i = self.payload;
537 let (j, before_or_after) = self.target;
538 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 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
558pub 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 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 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}