diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 89f44fb1ba6..58f5143f409 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -600,6 +600,7 @@ impl Default for Context { // Register built-in plugins: crate::debug_text::register(&ctx); crate::text_selection::LabelSelectionState::register(&ctx); + crate::DragAndDrop::register(&ctx); ctx } diff --git a/crates/egui/src/drag_and_drop.rs b/crates/egui/src/drag_and_drop.rs new file mode 100644 index 00000000000..b0c26ac8489 --- /dev/null +++ b/crates/egui/src/drag_and_drop.rs @@ -0,0 +1,125 @@ +use std::{any::Any, sync::Arc}; + +use crate::{Context, CursorIcon, Id}; + +/// Tracking of drag-and-drop payload. +/// +/// This is a low-level API. +/// +/// For a higher-level API, see: +/// - [`crate::Ui::dnd_drag_source`] +/// - [`crate::Ui::dnd_drop_zone`] +/// - [`crate::Response::dnd_set_drag_payload`] +/// - [`crate::Response::dnd_hover_payload`] +/// - [`crate::Response::dnd_release_payload`] +/// +/// See [this example](https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/demo/drag_and_drop.rs). +#[doc(alias = "drag and drop")] +#[derive(Clone, Default)] +pub struct DragAndDrop { + /// If set, something is currently being dragged + payload: Option>, +} + +impl DragAndDrop { + pub(crate) fn register(ctx: &Context) { + ctx.on_end_frame("debug_text", std::sync::Arc::new(Self::end_frame)); + } + + fn end_frame(ctx: &Context) { + let pointer_released = ctx.input(|i| i.pointer.any_released()); + + let mut is_dragging = false; + + ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(Id::NULL); + + if pointer_released { + state.payload = None; + } + + is_dragging = state.payload.is_some(); + }); + + if is_dragging { + ctx.set_cursor_icon(CursorIcon::Grabbing); + } + } + + /// Set a drag-and-drop payload. + /// + /// This can be read by [`Self::payload`] until the pointer is released. + pub fn set_payload(ctx: &Context, payload: Payload) + where + Payload: Any + Send + Sync, + { + ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(Id::NULL); + state.payload = Some(Arc::new(payload)); + }); + } + + /// Clears the payload, setting it to `None`. + pub fn clear_payload(ctx: &Context) { + ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(Id::NULL); + state.payload = None; + }); + } + + /// Retrieve the payload, if any. + /// + /// Returns `None` if there is no payload, or if it is not of the requested type. + /// + /// Returns `Some` both during a drag and on the frame the pointer is released + /// (if there is a payload). + pub fn payload(ctx: &Context) -> Option> + where + Payload: Any + Send + Sync, + { + ctx.data(|data| { + let state = data.get_temp::(Id::NULL)?; + let payload = state.payload?; + payload.downcast().ok() + }) + } + + /// Retrieve and clear the payload, if any. + /// + /// Returns `None` if there is no payload, or if it is not of the requested type. + /// + /// Returns `Some` both during a drag and on the frame the pointer is released + /// (if there is a payload). + pub fn take_payload(ctx: &Context) -> Option> + where + Payload: Any + Send + Sync, + { + ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(Id::NULL); + let payload = state.payload.take()?; + payload.downcast().ok() + }) + } + + /// Are we carrying a payload of the given type? + /// + /// Returns `true` both during a drag and on the frame the pointer is released + /// (if there is a payload). + pub fn has_payload_of_type(ctx: &Context) -> bool + where + Payload: Any + Send + Sync, + { + Self::payload::(ctx).is_some() + } + + /// Are we carrying a payload? + /// + /// Returns `true` both during a drag and on the frame the pointer is released + /// (if there is a payload). + pub fn has_any_payload(ctx: &Context) -> bool { + ctx.data(|data| { + let state = data.get_temp::(Id::NULL); + state.map_or(false, |state| state.payload.is_some()) + }) + } +} diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index cd1882411d6..67cfacc2c31 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -348,6 +348,7 @@ pub mod containers; mod context; mod data; pub mod debug_text; +mod drag_and_drop; mod frame_state; pub(crate) mod grid; pub mod gui_zoom; @@ -417,6 +418,7 @@ pub use { }, Key, }, + drag_and_drop::DragAndDrop, grid::Grid, id::{Id, IdMap}, input_state::{InputState, MultiTouchInfo, PointerState}, diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 178c28a6798..027938fb18c 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -1,3 +1,5 @@ +use std::{any::Any, sync::Arc}; + use crate::{ emath::{Align, Pos2, Rect, Vec2}, menu, Context, CursorIcon, Id, LayerId, PointerButton, Sense, Ui, WidgetText, @@ -68,7 +70,7 @@ pub struct Response { #[doc(hidden)] pub drag_started: bool, - /// The widgets is being dragged. + /// The widget is being dragged. #[doc(hidden)] pub dragged: bool, @@ -164,7 +166,7 @@ impl Response { // self.rect. See Context::interact. // This means we can be hovered and clicked even though `!self.rect.contains(pos)` is true, // hence the extra complexity here. - if self.hovered() { + if self.contains_pointer() { false } else if let Some(pos) = pointer.interact_pos() { !self.rect.contains(pos) @@ -279,7 +281,7 @@ impl Response { self.drag_started() && self.ctx.input(|i| i.pointer.button_down(button)) } - /// The widgets is being dragged. + /// The widget is being dragged. /// /// To find out which button(s), use [`Self::dragged_by`]. /// @@ -288,6 +290,8 @@ impl Response { /// or the user has pressed down for long enough. /// See [`crate::input_state::PointerState::is_decidedly_dragging`] for details. /// + /// If you want to avoid the delay, use [`Self::is_pointer_button_down_on`] instead. + /// /// If the widget is NOT sensitive to drags, this will always be `false`. /// [`crate::DragValue`] senses drags; [`crate::Label`] does not (unless you call [`crate::Label::sense`]). /// You can use [`Self::interact`] to sense more things *after* adding a widget. @@ -296,6 +300,7 @@ impl Response { self.dragged } + /// See [`Self::dragged`]. #[inline] pub fn dragged_by(&self, button: PointerButton) -> bool { self.dragged() && self.ctx.input(|i| i.pointer.button_down(button)) @@ -322,6 +327,51 @@ impl Response { } } + /// If the user started dragging this widget this frame, store the payload for drag-and-drop. + #[doc(alias = "drag and drop")] + pub fn dnd_set_drag_payload(&self, payload: Payload) { + if self.drag_started() { + crate::DragAndDrop::set_payload(&self.ctx, payload); + } + + if self.hovered() && !self.sense.click { + // Things that can be drag-dropped should use the Grab cursor icon, + // but if the thing is _also_ clickable, that can be annoying. + self.ctx.set_cursor_icon(CursorIcon::Grab); + } + } + + /// Drag-and-Drop: Return what is being held over this widget, if any. + /// + /// Only returns something if [`Self::contains_pointer`] is true, + /// and the user is drag-dropping something of this type. + #[doc(alias = "drag and drop")] + pub fn dnd_hover_payload(&self) -> Option> { + // NOTE: we use `response.contains_pointer` here instead of `hovered`, because + // `hovered` is always false when another widget is being dragged. + if self.contains_pointer() { + crate::DragAndDrop::payload::(&self.ctx) + } else { + None + } + } + + /// Drag-and-Drop: Return what is being dropped onto this widget, if any. + /// + /// Only returns something if [`Self::contains_pointer`] is true, + /// the user is drag-dropping something of this type, + /// and they released it this frame + #[doc(alias = "drag and drop")] + pub fn dnd_release_payload(&self) -> Option> { + // NOTE: we use `response.contains_pointer` here instead of `hovered`, because + // `hovered` is always false when another widget is being dragged. + if self.contains_pointer() && self.ctx.input(|i| i.pointer.any_released()) { + crate::DragAndDrop::take_payload::(&self.ctx) + } else { + None + } + } + /// Where the pointer (mouse/touch) were when when this widget was clicked or dragged. /// /// `None` if the widget is not being interacted with. @@ -705,6 +755,8 @@ impl Response { /// Response to secondary clicks (right-clicks) by showing the given menu. /// + /// Make sure the widget senses clicks (e.g. [`crate::Button`] does, [`crate::Label`] does not). + /// /// ``` /// # use egui::{Label, Sense}; /// # egui::__run_test_ui(|ui| { diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index ceb3639a091..658b867657f 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1,8 +1,7 @@ #![warn(missing_docs)] // Let's keep `Ui` well-documented. #![allow(clippy::use_self)] -use std::hash::Hash; -use std::sync::Arc; +use std::{any::Any, hash::Hash, sync::Arc}; use epaint::mutex::RwLock; @@ -2121,6 +2120,108 @@ impl Ui { result } + /// Create something that can be drag-and-dropped. + /// + /// The `id` needs to be globally unique. + /// The payload is what will be dropped if the user starts dragging. + /// + /// In contrast to [`Response::dnd_set_drag_payload`], + /// this function will paint the widget at the mouse cursor while the user is dragging. + #[doc(alias = "drag and drop")] + pub fn dnd_drag_source( + &mut self, + id: Id, + payload: Payload, + add_contents: impl FnOnce(&mut Self) -> R, + ) -> InnerResponse + where + Payload: Any + Send + Sync, + { + let is_being_dragged = self.memory(|mem| mem.is_being_dragged(id)); + + if is_being_dragged { + // Paint the body to a new layer: + let layer_id = LayerId::new(Order::Tooltip, id); + let InnerResponse { inner, response } = self.with_layer_id(layer_id, add_contents); + + // Now we move the visuals of the body to where the mouse is. + // Normally you need to decide a location for a widget first, + // because otherwise that widget cannot interact with the mouse. + // However, a dragged component cannot be interacted with anyway + // (anything with `Order::Tooltip` always gets an empty [`Response`]) + // So this is fine! + + if let Some(pointer_pos) = self.ctx().pointer_interact_pos() { + let delta = pointer_pos - response.rect.center(); + self.ctx().translate_layer(layer_id, delta); + } + + InnerResponse::new(inner, response) + } else { + let InnerResponse { inner, response } = self.scope(add_contents); + + // Check for drags: + let dnd_response = self.interact(response.rect, id, Sense::drag()); + + dnd_response.dnd_set_drag_payload(payload); + + InnerResponse::new(inner, dnd_response | response) + } + } + + /// Surround the given ui with a frame which + /// changes colors when you can drop something onto it. + /// + /// Returns the dropped item, if it was released this frame. + /// + /// The given frame is used for its margins, but it color is ignored. + #[doc(alias = "drag and drop")] + pub fn dnd_drop_zone( + &mut self, + frame: Frame, + add_contents: impl FnOnce(&mut Ui), + ) -> (Response, Option>) + where + Payload: Any + Send + Sync, + { + let is_anything_being_dragged = DragAndDrop::has_any_payload(self.ctx()); + let can_accept_what_is_being_dragged = + DragAndDrop::has_payload_of_type::(self.ctx()); + + let mut frame = frame.begin(self); + add_contents(&mut frame.content_ui); + let response = frame.allocate_space(self); + + // NOTE: we use `response.contains_pointer` here instead of `hovered`, because + // `hovered` is always false when another widget is being dragged. + let style = if is_anything_being_dragged + && can_accept_what_is_being_dragged + && response.contains_pointer() + { + self.visuals().widgets.active + } else { + self.visuals().widgets.inactive + }; + + let mut fill = style.bg_fill; + let mut stroke = style.bg_stroke; + + if is_anything_being_dragged && !can_accept_what_is_being_dragged { + // When dragging something else, show that it can't be dropped here: + fill = self.visuals().gray_out(fill); + stroke.color = self.visuals().gray_out(stroke.color); + } + + frame.frame.fill = fill; + frame.frame.stroke = stroke; + + frame.paint(self); + + let payload = response.dnd_release_payload::(); + + (response, payload) + } + /// Close the menu we are in (including submenus), if any. /// /// See also: [`Self::menu_button`] and [`Response::context_menu`]. diff --git a/crates/egui_demo_lib/src/demo/drag_and_drop.rs b/crates/egui_demo_lib/src/demo/drag_and_drop.rs index 37c21238047..29bda3350ac 100644 --- a/crates/egui_demo_lib/src/demo/drag_and_drop.rs +++ b/crates/egui_demo_lib/src/demo/drag_and_drop.rs @@ -1,78 +1,5 @@ use egui::*; -pub fn drag_source(ui: &mut Ui, id: Id, body: impl FnOnce(&mut Ui)) { - let is_being_dragged = ui.memory(|mem| mem.is_being_dragged(id)); - - if !is_being_dragged { - let response = ui.scope(body).response; - - // Check for drags: - let response = ui.interact(response.rect, id, Sense::drag()); - if response.hovered() { - ui.ctx().set_cursor_icon(CursorIcon::Grab); - } - } else { - ui.ctx().set_cursor_icon(CursorIcon::Grabbing); - - // Paint the body to a new layer: - let layer_id = LayerId::new(Order::Tooltip, id); - let response = ui.with_layer_id(layer_id, body).response; - - // Now we move the visuals of the body to where the mouse is. - // Normally you need to decide a location for a widget first, - // because otherwise that widget cannot interact with the mouse. - // However, a dragged component cannot be interacted with anyway - // (anything with `Order::Tooltip` always gets an empty [`Response`]) - // So this is fine! - - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let delta = pointer_pos - response.rect.center(); - ui.ctx().translate_layer(layer_id, delta); - } - } -} - -pub fn drop_target( - ui: &mut Ui, - can_accept_what_is_being_dragged: bool, - body: impl FnOnce(&mut Ui) -> R, -) -> InnerResponse { - let is_being_dragged = ui.memory(|mem| mem.is_anything_being_dragged()); - - let margin = Vec2::splat(4.0); - - let outer_rect_bounds = ui.available_rect_before_wrap(); - let inner_rect = outer_rect_bounds.shrink2(margin); - let where_to_put_background = ui.painter().add(Shape::Noop); - let mut content_ui = ui.child_ui(inner_rect, *ui.layout()); - let ret = body(&mut content_ui); - let outer_rect = Rect::from_min_max(outer_rect_bounds.min, content_ui.min_rect().max + margin); - let (rect, response) = ui.allocate_at_least(outer_rect.size(), Sense::hover()); - - // NOTE: we use `response.contains_pointer` here instead of `hovered`, because - // `hovered` is always false when another widget is being dragged. - let style = - if is_being_dragged && can_accept_what_is_being_dragged && response.contains_pointer() { - ui.visuals().widgets.active - } else { - ui.visuals().widgets.inactive - }; - - let mut fill = style.bg_fill; - let mut stroke = style.bg_stroke; - if is_being_dragged && !can_accept_what_is_being_dragged { - fill = ui.visuals().gray_out(fill); - stroke.color = ui.visuals().gray_out(stroke.color); - } - - ui.painter().set( - where_to_put_background, - epaint::RectShape::new(rect, style.rounding, fill, stroke), - ); - - InnerResponse::new(ret, response) -} - #[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct DragAndDropDemo { @@ -84,9 +11,9 @@ impl Default for DragAndDropDemo { fn default() -> Self { Self { columns: vec![ - vec!["Item A", "Item B", "Item C"], - vec!["Item D", "Item E"], - vec!["Item F", "Item G", "Item H"], + vec!["Item A", "Item B", "Item C", "Item D"], + vec!["Item E", "Item F", "Item G"], + vec!["Item H", "Item I", "Item J", "Item K"], ] .into_iter() .map(|v| v.into_iter().map(ToString::to_string).collect()) @@ -111,66 +38,100 @@ impl super::Demo for DragAndDropDemo { } } +/// What is being dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Location { + col: usize, + row: usize, +} + impl super::View for DragAndDropDemo { fn ui(&mut self, ui: &mut Ui) { - ui.label("This is a proof-of-concept of drag-and-drop in egui."); + ui.label("This is a simple example of drag-and-drop in egui."); ui.label("Drag items between columns."); - let id_source = "my_drag_and_drop_demo"; - let mut source_col_row = None; - let mut drop_col = None; + // If there is a drop, store the location of the item being dragged, and the destination for the drop. + let mut from = None; + let mut to = None; + ui.columns(self.columns.len(), |uis| { for (col_idx, column) in self.columns.clone().into_iter().enumerate() { let ui = &mut uis[col_idx]; - let can_accept_what_is_being_dragged = true; // We accept anything being dragged (for now) ¯\_(ツ)_/¯ - let response = drop_target(ui, can_accept_what_is_being_dragged, |ui| { + + let frame = Frame::default().inner_margin(4.0); + + let (_, dropped_payload) = ui.dnd_drop_zone::(frame, |ui| { ui.set_min_size(vec2(64.0, 100.0)); for (row_idx, item) in column.iter().enumerate() { - let item_id = Id::new(id_source).with(col_idx).with(row_idx); - drag_source(ui, item_id, |ui| { - let response = ui.add(Label::new(item).sense(Sense::click())); - response.context_menu(|ui| { - if ui.button("Remove").clicked() { - self.columns[col_idx].remove(row_idx); - ui.close_menu(); - } - }); - }); - - if ui.memory(|mem| mem.is_being_dragged(item_id)) { - source_col_row = Some((col_idx, row_idx)); + let item_id = Id::new(("my_drag_and_drop_demo", col_idx, row_idx)); + let item_location = Location { + col: col_idx, + row: row_idx, + }; + let response = ui + .dnd_drag_source(item_id, item_location, |ui| { + ui.label(item); + }) + .response; + + // Detect drops onto this item: + if let (Some(pointer), Some(hovered_payload)) = ( + ui.input(|i| i.pointer.interact_pos()), + response.dnd_hover_payload::(), + ) { + let rect = response.rect; + + // Preview insertion: + let stroke = egui::Stroke::new(1.0, Color32::WHITE); + let insert_row_idx = if *hovered_payload == item_location { + // We are dragged onto ourselves + ui.painter().hline(rect.x_range(), rect.center().y, stroke); + row_idx + } else if pointer.y < rect.center().y { + // Above us + ui.painter().hline(rect.x_range(), rect.top(), stroke); + row_idx + } else { + // Below us + ui.painter().hline(rect.x_range(), rect.bottom(), stroke); + row_idx + 1 + }; + + if let Some(dragged_payload) = response.dnd_release_payload() { + // The user dropped onto this item. + from = Some(dragged_payload); + to = Some(Location { + col: col_idx, + row: insert_row_idx, + }); + } } } - }) - .response; - - let response = response.context_menu(|ui| { - if ui.button("New Item").clicked() { - self.columns[col_idx].push("New Item".to_owned()); - ui.close_menu(); - } }); - let is_being_dragged = ui.memory(|mem| mem.is_anything_being_dragged()); - // NOTE: we use `response.contains_pointer` here instead of `hovered`, because - // `hovered` is always false when another widget is being dragged. - if is_being_dragged - && can_accept_what_is_being_dragged - && response.contains_pointer() - { - drop_col = Some(col_idx); + if let Some(dragged_payload) = dropped_payload { + // The user dropped onto the column, but not on any one item. + from = Some(dragged_payload); + to = Some(Location { + col: col_idx, + row: usize::MAX, // Inset last + }); } } }); - if let Some((source_col, source_row)) = source_col_row { - if let Some(drop_col) = drop_col { - if ui.input(|i| i.pointer.any_released()) { - // do the drop: - let item = self.columns[source_col].remove(source_row); - self.columns[drop_col].push(item); - } + if let (Some(from), Some(mut to)) = (from, to) { + if from.col == to.col { + // Dragging within the same column. + // Adjust row index if we are re-ordering: + to.row -= (from.row < to.row) as usize; } + + let item = self.columns[from.col].remove(from.row); + + let column = &mut self.columns[to.col]; + to.row = to.row.min(column.len()); + column.insert(to.row, item); } ui.vertical_centered(|ui| {