From 176295726ae20efa5e6400a7ec0f34d4d8a2171d Mon Sep 17 00:00:00 2001 From: Antoine Beyeler <49431240+abey79@users.noreply.github.com> Date: Tue, 30 Jan 2024 16:49:30 +0100 Subject: [PATCH] Add support for drag-and-drop in blueprint tree (#4910) ### What This PR adds support for drag-and-drop in the blueprint tree. - Fixes https://github.com/rerun-io/rerun/issues/2652 https://github.com/rerun-io/rerun/assets/49431240/4d5f47ae-6d79-4ad0-a05d-b7eaaac18659 It has the following known limitations/future work: - Only a single item may be dragged at a time. If multiple items are selected when a drag starts, the selection is set to contain only the dragged item. - https://github.com/rerun-io/rerun/issues/4887 - Only space view and containers may be dragged. The drag and drop operations only operate on the view hierarchy (not the space view data). - The root container has a special behaviour: it cannot be successfully dragged, and nothing can be dragged before/after it. This is consistent with it being the root container, but inconsistent with how the UI is presented. - https://github.com/rerun-io/rerun/issues/4909 - Because of how Grid container work (they support "holes", and tend to swap instead of reflow items when reordering), some drag operation have additional order side effects within the destination (grid) container. - https://github.com/rerun-io/rerun/issues/4916 ### Checklist * [x] I have read and agree to [Contributor Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md) * [x] I've included a screenshot or gif (if applicable) * [x] I have tested the web demo (if applicable): * Using newly built examples: [app.rerun.io](https://app.rerun.io/pr/4910/index.html) * Using examples from latest `main` build: [app.rerun.io](https://app.rerun.io/pr/4910/index.html?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) * Using full set of examples from `nightly` build: [app.rerun.io](https://app.rerun.io/pr/4910/index.html?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) * [x] The PR title and labels are set such as to maximize their usefulness for the next release's CHANGELOG - [PR Build Summary](https://build.rerun.io/pr/4910) - [Docs preview](https://rerun.io/preview/b4084fe32cd0466fe6acd4807a33545091c9e1ba/docs) - [Examples preview](https://rerun.io/preview/b4084fe32cd0466fe6acd4807a33545091c9e1ba/examples) - [Recent benchmark results](https://build.rerun.io/graphs/crates.html) - [Wasm size tracking](https://build.rerun.io/graphs/sizes.html) --------- Co-authored-by: Emil Ernerfeldt --- Cargo.lock | 3 +- Cargo.toml | 2 +- crates/re_ui/examples/re_ui_example.rs | 351 ++++-------------- crates/re_ui/src/drag_and_drop.rs | 320 ++++++++++++++++ crates/re_ui/src/lib.rs | 1 + crates/re_ui/src/list_item.rs | 33 +- crates/re_viewer/src/ui/selection_panel.rs | 2 +- crates/re_viewport/src/container.rs | 26 +- crates/re_viewport/src/viewport.rs | 58 ++- crates/re_viewport/src/viewport_blueprint.rs | 121 ++++++ .../re_viewport/src/viewport_blueprint_ui.rs | 210 ++++++++++- 11 files changed, 797 insertions(+), 330 deletions(-) create mode 100644 crates/re_ui/src/drag_and_drop.rs diff --git a/Cargo.lock b/Cargo.lock index 0224c5f84af2..81768919be4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1704,8 +1704,7 @@ dependencies = [ [[package]] name = "egui_tiles" version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0255c0209b349b1a4a67a344556501e75accae669f3a25be6e07deb30fefa91" +source = "git+https://github.com/rerun-io/egui_tiles?rev=35e711283e7a021ca425d9fbd8e7581548971f49#35e711283e7a021ca425d9fbd8e7581548971f49" dependencies = [ "ahash", "egui", diff --git a/Cargo.toml b/Cargo.toml index 1927bbb002fc..12161d16a557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -294,6 +294,6 @@ emath = { git = "https://github.com/emilk/egui.git", rev = "ab39420c2933d2e40299 # egui-wgpu = { path = "../../egui/crates/egui-wgpu" } # emath = { path = "../../egui/crates/emath" } -# egui_tiles = { git = "https://github.com/rerun-io/egui_tiles", rev = "b6e4fd457b2eee2c671747ead12f4a20feb380e8" } # Merge of: https://github.com/rerun-io/egui_tiles/pull/41 +egui_tiles = { git = "https://github.com/rerun-io/egui_tiles", rev = "35e711283e7a021ca425d9fbd8e7581548971f49" } # master 2024-01-26 # egui_commonmark = { git = "https://github.com/rerun-io/egui_commonmark", rev = "3d83a92f995a1d18ab1172d0b129d496e0eedaae" } # Update to egui 0.25 https://github.com/lampsitter/egui_commonmark/pull/27 diff --git a/crates/re_ui/examples/re_ui_example.rs b/crates/re_ui/examples/re_ui_example.rs index b47f503d7afb..411f418b9d05 100644 --- a/crates/re_ui/examples/re_ui_example.rs +++ b/crates/re_ui/examples/re_ui_example.rs @@ -666,21 +666,18 @@ mod drag_and_drop { impl ExampleDragAndDrop { pub fn ui(&mut self, re_ui: &crate::ReUi, ui: &mut egui::Ui) { - let mut source_item_position_index = None; - let mut target_item_position_index = None; + let mut swap: Option<(usize, usize)> = None; for (i, item_id) in self.items.iter().enumerate() { // // Draw the item // - let id = egui::Id::new("drag_demo").with(*item_id); - let label = format!("Item {}", item_id.0); let response = re_ui .list_item(label.as_str()) .selected(self.selected_items.contains(item_id)) - .draggable(id) + .draggable(true) .show(ui); // @@ -702,24 +699,20 @@ mod drag_and_drop { } // Drag-and-drop of multiple items not (yet?) supported, so dragging resets selection to single item. - if response.dragged() { + if response.drag_started() { self.selected_items.clear(); self.selected_items.insert(*item_id); + + response.dnd_set_drag_payload(i); } // - // Detect end-of-drag situation and prepare the swap command. + // Detect drag situation and run the swap if it ends. // - if response.dragged() || response.drag_released() { - source_item_position_index = Some(i); - } + let source_item_position_index = egui::DragAndDrop::payload(ui.ctx()).map(|i| *i); - // TODO(emilk/egui#3882): this feels like a common enough pattern that is should deserve its own API. - let anything_being_decidedly_dragged = ui - .memory(|mem| mem.is_anything_being_dragged()) - && ui.input(|i| i.pointer.is_decidedly_dragging()); - if anything_being_decidedly_dragged { + if let Some(source_item_position_index) = source_item_position_index { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); let (top, bottom) = response.rect.split_top_bottom_at_fraction(0.5); @@ -732,16 +725,19 @@ mod drag_and_drop { (None, None) }; - if let Some(insert_y) = insert_y { + if let (Some(insert_y), Some(target)) = (insert_y, target) { ui.painter().hline( ui.cursor().x_range(), insert_y, (2.0, egui::Color32::WHITE), ); - // TODO(emilk/egui#3882): it would be nice to have a drag specific API for that + // note: can't use `response.drag_released()` because we not the item which + // started the drag if ui.input(|i| i.pointer.any_released()) { - target_item_position_index = target; + swap = Some((source_item_position_index, target)); + + egui::DragAndDrop::clear_payload(ui.ctx()); } } } @@ -751,9 +747,7 @@ mod drag_and_drop { // Handle the swap command (if any) // - if let (Some(source), Some(target)) = - (source_item_position_index, target_item_position_index) - { + if let Some((source, target)) = swap { if source != target { let item = self.items.remove(source); @@ -1054,7 +1048,7 @@ mod hierarchical_drag_and_drop { .list_item(format!("Container {item_id:?}")) .subdued(true) .selected(self.selected(item_id)) - .draggable(item_id.into()) + .draggable(true) .drop_target_style(self.target_container == Some(item_id)) .show_collapsing(ui, item_id.into(), true, |re_ui, ui| { self.container_children_ui(re_ui, ui, children); @@ -1092,7 +1086,7 @@ mod hierarchical_drag_and_drop { let response = re_ui .list_item(label) .selected(self.selected(item_id)) - .draggable(item_id.into()) + .draggable(true) .show(ui); self.handle_interaction(ui, item_id, false, &response, None); @@ -1122,308 +1116,87 @@ mod hierarchical_drag_and_drop { // handle drag // - if response.dragged() { + if response.drag_started() { // Here, we support dragging a single item at a time, so we set the selection to the dragged item // if/when we're dragging it proper. self.send_command(Command::SetSelection(item_id)); + + egui::DragAndDrop::set_payload(ui.ctx(), item_id); } // // handle drop // - let anything_being_decidedly_dragged = ui.memory(|mem| mem.is_anything_being_dragged()) - && ui.input(|i| i.pointer.is_decidedly_dragging()); - - if !anything_being_decidedly_dragged { - // nothing to do + // find the item being dragged + let Some(dragged_item_id) = + egui::DragAndDrop::payload(ui.ctx()).map(|payload| (*payload)) + else { + // nothing is being dragged, we're done here return; - } + }; - // find the item being dragged - // TODO(ab): `mem.dragged_id()` now exists but there is no easy way to get the value out of and egui::Id - let Some(dragged_item_id) = ui.memory(|mem| { - self.items - .keys() - .find(|item_id| mem.is_being_dragged((**item_id).into())) - .copied() - }) else { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + + let Some((parent_id, position_index_in_parent)) = self.parent_and_pos(item_id) else { // this shouldn't happen return; }; - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let previous_container_id = if position_index_in_parent > 0 { + self.container(parent_id) + .map(|c| c[position_index_in_parent - 1]) + .filter(|id| self.container(*id).is_some()) + } else { + None + }; - let drag_target = - self.find_drop_target(ui, item_id, is_container, response, body_response); + let item_desc = re_ui::drag_and_drop::ItemContext { + id: item_id, + is_container, + parent_id, + position_index_in_parent, + previous_container_id, + }; - if let Some(drag_target) = drag_target { + let drop_target = re_ui::drag_and_drop::find_drop_target( + ui, + &item_desc, + response.rect, + body_response.map(|r| r.rect), + ReUi::list_item_height(), + ); + + if let Some(drop_target) = drop_target { // We cannot allow the target location to be "inside" the dragged item, because that would amount moving // myself inside of me. - if self.contains(dragged_item_id, drag_target.target_parent_id) { + if self.contains(dragged_item_id, drop_target.target_parent_id) { return; } ui.painter().hline( - drag_target.indicator_span_x, - drag_target.indicator_position_y, + drop_target.indicator_span_x, + drop_target.indicator_position_y, (2.0, egui::Color32::WHITE), ); - // TODO(emilk/egui#3882): it would be nice to have a drag specific API for `ctx().drag_stopped()`. + // note: can't use `response.drag_released()` because we not the item which + // started the drag if ui.input(|i| i.pointer.any_released()) { self.send_command(Command::MoveItem { moved_item_id: dragged_item_id, - target_container_id: drag_target.target_parent_id, - target_position_index: drag_target.target_position_index, + target_container_id: drop_target.target_parent_id, + target_position_index: drop_target.target_position_index, }); + + egui::DragAndDrop::clear_payload(ui.ctx()); } else { self.send_command(Command::HighlightTargetContainer( - drag_target.target_parent_id, + drop_target.target_parent_id, )); } } } - - /// Compute the geometry of the drag cursor and where the dragged item should be inserted. - /// - /// This function implements the following logic: - /// ```text - /// - /// insert insert last in container before me - /// before me (if any) or insert before me - /// │ │ - /// ╔═══▼═════════════════════════════▼══════════════════╗ - /// ║ │ ║ - /// leaf item ║ ─────┴──────────────────────────────────────────── ║ - /// ║ ║ - /// ╚═════════════════════▲══════════════════════════════╝ - /// │ - /// insert after me - /// - /// - /// - /// insert insert last in container before me - /// before me (if any) or insert before me - /// │ │ - /// ╔═══▼═════════════════════════════▼══════════════════╗ - /// container item ║ │ ║ - /// (no/collapsed ║ ─────┼──────────────────────────────────────────── ║ - /// body) ║ │ ║ - /// ╚═══▲═════════════════════════════▲══════════════════╝ - /// │ │ - /// insert insert inside me - /// after me at pos = 0 - /// - /// - /// - /// insert insert last in container before me - /// before me (if any) or insert before me - /// │ │ - /// ╔═══▼═════════════════════════════▼══════════════════╗ - /// container item ║ │ ║ - /// with body ║ ─────┴──────────────────────────────────────────── ║ - /// ║ ║ - /// ╚══▲═══╦═════════════════════════════════════════════╣ ─┐ - /// │ ║ ║ │ - /// insert ║ ║ │ - /// inside me ║ ║ │ - /// at pos = 0 ╠══ ══╣ │ - /// ║ same logic ║ │ - /// ║ recursively ║ │ body - /// insert ║ applied here ║ │ - /// after me ╠══ ══╣ │ - /// │ ║ ║ │ - /// ┌──▼── ║ ║ │ - /// │ ║ ║ │ - /// └───── ╚═════════════════════════════════════════════╝ ─┘ - /// - /// ``` - /// - /// **Note**: press `Alt` to visualize the drag zones while dragging. - fn find_drop_target( - &self, - ui: &egui::Ui, - item_id: ItemId, - is_container: bool, - response: &egui::Response, - body_response: Option<&egui::Response>, - ) -> Option { - let indent = ui.spacing().indent; - - // For both leaf and containers we have two drag zones on the upper half of the item. - let (top, mut bottom) = response.rect.split_top_bottom_at_fraction(0.5); - let (left_top, top) = top.split_left_right_at_x(top.left() + indent); - - // For the lower part of the item, the story is more complicated: - // - for leaf item, we have a single drag zone on the entire lower half - // - for container item, we must distinguish between the indent part and the rest, plus check some area in the - // body - let mut left_bottom = egui::Rect::NOTHING; - if is_container { - (left_bottom, bottom) = bottom.split_left_right_at_x(bottom.left() + indent); - } - - let mut content_left_bottom = egui::Rect::NOTHING; - if let Some(body_response) = body_response { - content_left_bottom = egui::Rect::from_two_pos( - body_response.rect.left_bottom() - + egui::vec2(indent, -ReUi::list_item_height() / 2.0), - body_response.rect.left_bottom(), - ); - } - - // Visualize the drag zones - if ui.input(|i| i.modifiers.alt) { - ui.ctx() - .debug_painter() - .debug_rect(top, egui::Color32::RED, "t"); - ui.ctx() - .debug_painter() - .debug_rect(bottom, egui::Color32::GREEN, "b"); - - ui.ctx().debug_painter().debug_rect( - left_top, - egui::Color32::RED.gamma_multiply(0.5), - "lt", - ); - ui.ctx().debug_painter().debug_rect( - left_bottom, - egui::Color32::GREEN.gamma_multiply(0.5), - "lb", - ); - ui.ctx().debug_painter().debug_rect( - content_left_bottom, - egui::Color32::YELLOW, - "c", - ); - } - - let Some((parent_id, pos_in_parent)) = self.parent_and_pos(item_id) else { - // this shouldn't happen - return None; - }; - - if ui.rect_contains_pointer(left_top) { - // insert before me - Some(DropTarget::new( - response.rect.x_range(), - top.top(), - parent_id, - pos_in_parent, - )) - } else if ui.rect_contains_pointer(top) { - // insert last in the previous container if any, else insert before me - let previous_container_id = if pos_in_parent > 0 { - self.container(parent_id) - .map(|c| c[pos_in_parent - 1]) - .filter(|id| self.container(*id).is_some()) - } else { - None - }; - - if let Some(previous_container_id) = previous_container_id { - Some(DropTarget::new( - (response.rect.left() + indent..=response.rect.right()).into(), - top.top(), - previous_container_id, - usize::MAX, - )) - } else { - Some(DropTarget::new( - response.rect.x_range(), - top.top(), - parent_id, - pos_in_parent, - )) - } - } else if !is_container { - if ui.rect_contains_pointer(bottom) { - // insert after me - Some(DropTarget::new( - response.rect.x_range(), - bottom.bottom(), - parent_id, - pos_in_parent + 1, - )) - } else { - None - } - } else { - let body_rect = body_response.map(|r| r.rect).filter(|r| r.width() > 0.0); - if let Some(body_rect) = body_rect { - if ui.rect_contains_pointer(left_bottom) || ui.rect_contains_pointer(bottom) { - // insert at pos = 0 inside me - Some(DropTarget::new( - (body_rect.left() + indent..=body_rect.right()).into(), - left_bottom.bottom(), - item_id, - 0, - )) - } else if ui.rect_contains_pointer(content_left_bottom) { - // insert after me in my parent - Some(DropTarget::new( - response.rect.x_range(), - content_left_bottom.bottom(), - parent_id, - pos_in_parent + 1, - )) - } else { - None - } - } else if ui.rect_contains_pointer(left_bottom) { - // insert after me in my parent - Some(DropTarget::new( - response.rect.x_range(), - left_bottom.bottom(), - parent_id, - pos_in_parent + 1, - )) - } else if ui.rect_contains_pointer(bottom) { - // insert at pos = 0 inside me - Some(DropTarget::new( - (response.rect.left() + indent..=response.rect.right()).into(), - bottom.bottom(), - item_id, - 0, - )) - } else { - None - } - } - } - } - - /// Gather information about a drop target, including the geometry of the drop indicator that should be - /// displayed, and the destination where the dragged items should be moved. - struct DropTarget { - /// Range of X coordinates for the drag target indicator - indicator_span_x: egui::Rangef, - - /// Y coordinate for drag target indicator - indicator_position_y: f32, - - /// Destination container ID - target_parent_id: ItemId, - - /// Destination position within the container - target_position_index: usize, - } - - impl DropTarget { - fn new( - indicator_span_x: egui::Rangef, - indicator_position_y: f32, - target_parent_id: ItemId, - target_position_index: usize, - ) -> Self { - Self { - indicator_span_x, - indicator_position_y, - target_parent_id, - target_position_index, - } - } } } diff --git a/crates/re_ui/src/drag_and_drop.rs b/crates/re_ui/src/drag_and_drop.rs new file mode 100644 index 000000000000..510d4f466a10 --- /dev/null +++ b/crates/re_ui/src/drag_and_drop.rs @@ -0,0 +1,320 @@ +//! Helpers for drag and drop support for reordering hierarchical lists. +//! +//! Works well in combination with [`crate::list_item::ListItem`]. + +/// Context information about the hovered item. +/// +/// This is used by [`find_drop_target`] to compute the [`DropTarget`], if any. +pub struct ItemContext { + /// ID of the item being hovered during drag + pub id: ItemId, + + /// Can this item "contain" the currently dragged item? + pub is_container: bool, + + /// ID of the parent of this item. + pub parent_id: ItemId, + + /// Position of this item within its parent. + pub position_index_in_parent: usize, + + /// ID of the container just before this item within the parent, if such a container exists. + pub previous_container_id: Option, +} + +/// Drop target information, including where to draw the drop indicator and where to insert the dragged item. +#[derive(Clone, Debug)] +pub struct DropTarget { + /// Range of X coordinates for the drag target indicator + pub indicator_span_x: egui::Rangef, + + /// Y coordinate for drag target indicator + pub indicator_position_y: f32, + + /// Destination container ID + pub target_parent_id: ItemId, + + /// Destination position within the container + pub target_position_index: usize, +} + +impl DropTarget { + pub fn new( + indicator_span_x: egui::Rangef, + indicator_position_y: f32, + target_parent_id: ItemId, + target_position_index: usize, + ) -> Self { + Self { + indicator_span_x, + indicator_position_y, + target_parent_id, + target_position_index, + } + } +} + +/// Compute the geometry of the drag cursor and where the dragged item should be inserted. +/// +/// This function performs the following tasks: +/// - based on `item_rect` and `body_rect`, establish the geometry of actual drop zones (see below) +/// - test the mouse cursor against these zones +/// - if one is a match: +/// - compute the geometry of a drop insertion indicator +/// - use the context provided in `item_context` to return the "logical" drop target (ie. the target container and +/// position within it) +/// +/// This function implements the following logic: +/// ```text +/// +/// insert insert last in container before me +/// before me (if any) or insert before me +/// │ │ +/// ╔═══▼═════════════════════════════▼══════════════════╗ +/// ║ │ ║ +/// leaf item ║ ─────┴──────────────────────────────────────────── ║ +/// ║ ║ +/// ╚═════════════════════▲══════════════════════════════╝ +/// │ +/// insert after me +/// +/// +/// insert insert last in container before me +/// before me (if any) or insert before me +/// │ │ +/// ╔═══▼═════════════════════════════▼══════════════════╗ +/// leaf item ║ │ ║ +/// with body ║ ─────┴──────────────────────────────────────────── ║ +/// ║ ║ +/// ╚══════╦══════════════════════════════════════▲══════╣ ─┐ +/// │ ║ │ ║ │ +/// │ ║ insert ║ │ +/// │ ║ after me ║ │ +/// │ ╠══ ══╣ │ +/// │ ║ no insertion possible ║ │ +/// │ ║ here by definition of ║ │ body +/// │ ║ parent being a leaf ║ │ +/// │ ╠══ ══╣ │ +/// │ ║ ║ │ +/// │ ║ ║ │ +/// │ ║ ║ │ +/// └──▲── ╚══════════════════════════▲══════════════════╝ ─┘ +/// │ │ +/// insert insert +/// after me after me +/// +/// +/// insert insert last in container before me +/// before me (if any) or insert before me +/// │ │ +/// ╔═══▼═════════════════════════════▼══════════════════╗ +/// container item ║ │ ║ +/// (empty/collapsed ║ ─────┼──────────────────────────────────────────── ║ +/// body) ║ │ ║ +/// ╚═══▲═════════════════════════════▲══════════════════╝ +/// │ │ +/// insert insert inside me +/// after me at pos = 0 +/// +/// +/// insert insert last in container before me +/// before me (if any) or insert before me +/// │ │ +/// ╔═══▼═════════════════════════════▼══════════════════╗ +/// container item ║ │ ║ +/// with body ║ ─────┴──────────────────────────────────────────── ║ +/// ║ ║ +/// ╚═▲════╦═════════════════════════════════════════════╣ ─┐ +/// │ ║ ║ │ +/// insert ║ ║ │ +/// inside me ║ ║ │ +/// at pos = 0 ╠══ ══╣ │ +/// ║ same logic ║ │ +/// ║ recursively ║ │ body +/// insert ║ applied here ║ │ +/// after me ╠══ ══╣ │ +/// │ ║ ║ │ +/// ┌─▼─── ║ ║ │ +/// │ ║ ║ │ +/// └───── ╚═════════════════════════════════════════════╝ ─┘ +/// ``` +/// +/// Here are a few observations of the above that help navigate the "if-statement-of-death" +/// in the implementation: +/// - The top parts of the item are treated the same in all four cases. +/// - Handling of the body can be simplified by making the sensitive area either a small +/// corner (container case), or the entire body (leaf case). Then, that area always maps +/// to "insert after me". +/// - The bottom parts have the most difference between cases and need case-by-case handling. +/// In both leaf item cases, the entire bottom part maps to "insert after me", though. +/// +/// **Note**: in debug builds, press `Alt` to visualize the drag zones while dragging. +pub fn find_drop_target( + ui: &egui::Ui, + item_context: &ItemContext, + item_rect: egui::Rect, + body_rect: Option, + item_height: f32, +) -> Option> { + let indent = ui.spacing().indent; + let item_id = item_context.id; + let is_container = item_context.is_container; + let parent_id = item_context.parent_id; + let pos_in_parent = item_context.position_index_in_parent; + + // For both leaf and containers we have two drag zones on the upper half of the item. + let (top, mut bottom) = item_rect.split_top_bottom_at_fraction(0.5); + let (left_top, top) = top.split_left_right_at_x(top.left() + indent); + + // For the lower part of the item, the story is more complicated: + // - for leaf item, we have a single drag zone on the entire lower half + // - for container item, we must distinguish between the indent part and the rest, plus check some area in the + // body + let mut left_bottom = egui::Rect::NOTHING; + if is_container { + (left_bottom, bottom) = bottom.split_left_right_at_x(bottom.left() + indent); + } + + // For the body area we have two cases: + // - container item: it's handled recursively by the nested items, so we only need to check a small area down + // left, which maps to "insert after me" + // - leaf item: the entire body area, if any, cannot receive a drag (by definition) and thus homogeneously maps + // to "insert after me" + let body_insert_after_me_area = if let Some(body_rect) = body_rect { + if item_context.is_container { + egui::Rect::from_two_pos( + body_rect.left_bottom() + egui::vec2(indent, -item_height / 2.0), + body_rect.left_bottom(), + ) + } else { + body_rect + } + } else { + egui::Rect::NOTHING + }; + + // body rect, if any AND it actually contains something + let non_empty_body_rect = body_rect.filter(|r| r.height() > 0.0); + + // visualize the drag zones in debug builds, when the `Alt` key is pressed during drag + #[cfg(debug_assertions)] + { + // Visualize the drag zones + if ui.input(|i| i.modifiers.alt) { + ui.ctx() + .debug_painter() + .debug_rect(top, egui::Color32::RED, "t"); + ui.ctx() + .debug_painter() + .debug_rect(bottom, egui::Color32::GREEN, "b"); + + ui.ctx().debug_painter().debug_rect( + left_top, + egui::Color32::RED.gamma_multiply(0.5), + "lt", + ); + ui.ctx().debug_painter().debug_rect( + left_bottom, + egui::Color32::GREEN.gamma_multiply(0.5), + "lb", + ); + ui.ctx().debug_painter().debug_rect( + body_insert_after_me_area, + egui::Color32::YELLOW, + "bdy", + ); + } + } + + /* ===== TOP SECTIONS (same leaf/container items) ==== */ + if ui.rect_contains_pointer(left_top) { + // insert before me + Some(DropTarget::new( + item_rect.x_range(), + top.top(), + parent_id, + pos_in_parent, + )) + } else if ui.rect_contains_pointer(top) { + // insert last in the previous container if any, else insert before me + if let Some(previous_container_id) = item_context.previous_container_id { + Some(DropTarget::new( + (item_rect.left() + indent..=item_rect.right()).into(), + top.top(), + previous_container_id, + usize::MAX, + )) + } else { + Some(DropTarget::new( + item_rect.x_range(), + top.top(), + parent_id, + pos_in_parent, + )) + } + } + /* ==== BODY SENSE AREA ==== */ + else if ui.rect_contains_pointer(body_insert_after_me_area) { + // insert after me in my parent + Some(DropTarget::new( + item_rect.x_range(), + body_insert_after_me_area.bottom(), + parent_id, + pos_in_parent + 1, + )) + } + /* ==== BOTTOM SECTIONS (leaf item) ==== */ + else if !is_container { + if ui.rect_contains_pointer(bottom) { + let position_y = if let Some(body_rect) = non_empty_body_rect { + body_rect.bottom() + } else { + bottom.bottom() + }; + + // insert after me + Some(DropTarget::new( + item_rect.x_range(), + position_y, + parent_id, + pos_in_parent + 1, + )) + } else { + None + } + } + /* ==== BOTTOM SECTIONS (container item) ==== */ + else if let Some(body_rect) = non_empty_body_rect { + if ui.rect_contains_pointer(left_bottom) || ui.rect_contains_pointer(bottom) { + // insert at pos = 0 inside me + Some(DropTarget::new( + (body_rect.left() + indent..=body_rect.right()).into(), + left_bottom.bottom(), + item_id, + 0, + )) + } else { + None + } + } else if ui.rect_contains_pointer(left_bottom) { + // insert after me in my parent + Some(DropTarget::new( + item_rect.x_range(), + left_bottom.bottom(), + parent_id, + pos_in_parent + 1, + )) + } else if ui.rect_contains_pointer(bottom) { + // insert at pos = 0 inside me + Some(DropTarget::new( + (item_rect.left() + indent..=item_rect.right()).into(), + bottom.bottom(), + item_id, + 0, + )) + } + /* ==== Who knows where else the mouse cursor might wander… ¯\_(ツ)_/¯ ==== */ + else { + None + } +} diff --git a/crates/re_ui/src/lib.rs b/crates/re_ui/src/lib.rs index 4d161ed870d7..d0afdc0e19e1 100644 --- a/crates/re_ui/src/lib.rs +++ b/crates/re_ui/src/lib.rs @@ -3,6 +3,7 @@ mod command; mod command_palette; mod design_tokens; +pub mod drag_and_drop; pub mod egui_helpers; pub mod icons; mod layout_job_builder; diff --git a/crates/re_ui/src/list_item.rs b/crates/re_ui/src/list_item.rs index 869f972d6759..057159b2489b 100644 --- a/crates/re_ui/src/list_item.rs +++ b/crates/re_ui/src/list_item.rs @@ -113,7 +113,7 @@ pub struct ListItem<'a> { re_ui: &'a ReUi, active: bool, selected: bool, - drag_id: Option, + draggable: bool, drag_target: bool, subdued: bool, weak: bool, @@ -136,7 +136,7 @@ impl<'a> ListItem<'a> { re_ui, active: true, selected: false, - drag_id: None, + draggable: false, drag_target: false, subdued: false, weak: false, @@ -165,10 +165,10 @@ impl<'a> ListItem<'a> { self } - /// Make the item draggable and set its persistent ID. + /// Make the item draggable. #[inline] - pub fn draggable(mut self, drag_id: egui::Id) -> Self { - self.drag_id = Some(drag_id); + pub fn draggable(mut self, draggable: bool) -> Self { + self.draggable = draggable; self } @@ -383,12 +383,14 @@ impl<'a> ListItem<'a> { }; let desired_size = egui::vec2(desired_width, self.height); - let (rect, mut response) = ui.allocate_at_least(desired_size, egui::Sense::click()); - - // handle dragging - if let Some(drag_id) = self.drag_id { - response = ui.interact(response.rect, drag_id, egui::Sense::drag()); - } + let (rect, mut response) = ui.allocate_at_least( + desired_size, + if self.draggable { + egui::Sense::click_and_drag() + } else { + egui::Sense::click() + }, + ); // compute the full-span background rect let mut bg_rect = rect; @@ -456,7 +458,14 @@ impl<'a> ListItem<'a> { } // Handle buttons - let button_response = if self.active && ui.rect_contains_pointer(rect) { + // Note: We should be able to just use `response.hovered()` here, which only returns `true` if no drag is in + // progress. Due to the response merging we do above, this breaks though. This is why we do an explicit + // rectangle and drag payload check. + //TODO(ab): refactor responses to address that. + let should_show_buttons = self.active + && ui.rect_contains_pointer(rect) + && !egui::DragAndDrop::has_any_payload(ui.ctx()); + let button_response = if should_show_buttons { if let Some(buttons) = self.buttons_fn { let mut ui = ui.child_ui(rect, egui::Layout::right_to_left(egui::Align::Center)); diff --git a/crates/re_viewer/src/ui/selection_panel.rs b/crates/re_viewer/src/ui/selection_panel.rs index 3b0986d83215..967233979ece 100644 --- a/crates/re_viewer/src/ui/selection_panel.rs +++ b/crates/re_viewer/src/ui/selection_panel.rs @@ -768,7 +768,7 @@ fn show_list_item_for_container_child( if remove_contents { viewport.blueprint.mark_user_interaction(ctx); - viewport.blueprint.remove_contents(child_contents.clone()); + viewport.blueprint.remove_contents(*child_contents); } true diff --git a/crates/re_viewport/src/container.rs b/crates/re_viewport/src/container.rs index 4f70870cf26e..6675337c7788 100644 --- a/crates/re_viewport/src/container.rs +++ b/crates/re_viewport/src/container.rs @@ -8,13 +8,13 @@ use re_query::query_archetype; use re_types::blueprint::components::Visible; use re_types_core::{archetypes::Clear, ArrowBuffer}; use re_viewer_context::{ - blueprint_timepoint_for_writes, BlueprintId, BlueprintIdRegistry, ContainerId, SpaceViewId, - SystemCommand, SystemCommandSender as _, ViewerContext, + blueprint_timepoint_for_writes, BlueprintId, BlueprintIdRegistry, ContainerId, Item, + SpaceViewId, SystemCommand, SystemCommandSender as _, ViewerContext, }; use crate::blueprint::components::GridColumns; -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Contents { Container(ContainerId), SpaceView(SpaceViewId), @@ -32,7 +32,7 @@ impl Contents { } #[inline] - fn to_entity_path(&self) -> EntityPath { + fn as_entity_path(&self) -> EntityPath { match self { Self::Container(id) => id.as_entity_path(), Self::SpaceView(id) => id.as_entity_path(), @@ -40,13 +40,21 @@ impl Contents { } #[inline] - pub fn to_tile_id(&self) -> TileId { + pub fn as_tile_id(&self) -> TileId { match self { Self::Container(id) => blueprint_id_to_tile_id(id), Self::SpaceView(id) => blueprint_id_to_tile_id(id), } } + #[inline] + pub fn as_item(&self) -> Item { + match self { + Contents::Container(container_id) => Item::Container(*container_id), + Contents::SpaceView(space_view_id) => Item::SpaceView(*space_view_id), + } + } + #[inline] pub fn as_container_id(&self) -> Option { match self { @@ -196,7 +204,7 @@ impl ContainerBlueprint { grid_columns, } = self; - let contents: Vec<_> = contents.iter().map(|item| item.to_entity_path()).collect(); + let contents: Vec<_> = contents.iter().map(|item| item.as_entity_path()).collect(); let col_shares: ArrowBuffer<_> = col_shares.clone().into(); let row_shares: ArrowBuffer<_> = row_shares.clone().into(); @@ -211,7 +219,7 @@ impl ContainerBlueprint { // TODO(jleibs): The need for this pattern is annoying. Should codegen // a version of this that can take an Option. if let Some(active_tab) = &active_tab { - arch = arch.with_active_tab(&active_tab.to_entity_path()); + arch = arch.with_active_tab(&active_tab.as_entity_path()); } if let Some(cols) = grid_columns { @@ -359,7 +367,7 @@ impl ContainerBlueprint { let children = self .contents .iter() - .map(|item| item.to_tile_id()) + .map(|item| item.as_tile_id()) .collect::>(); let container = match self.container_kind { @@ -368,7 +376,7 @@ impl ContainerBlueprint { tabs.active = self .active_tab .as_ref() - .map(|id| id.to_tile_id()) + .map(|id| id.as_tile_id()) .or_else(|| tabs.children.first().copied()); egui_tiles::Container::Tabs(tabs) } diff --git a/crates/re_viewport/src/viewport.rs b/crates/re_viewport/src/viewport.rs index c73e4953ab26..b39340243ae6 100644 --- a/crates/re_viewport/src/viewport.rs +++ b/crates/re_viewport/src/viewport.rs @@ -5,12 +5,11 @@ use std::collections::BTreeMap; use ahash::HashMap; - use egui_tiles::Behavior as _; use once_cell::sync::Lazy; + use re_data_ui::item_ui; use re_entity_db::EntityPropertyMap; - use re_ui::{Icon, ReUi}; use re_viewer_context::{ AppOptions, ContainerId, Item, SpaceViewClassIdentifier, SpaceViewClassRegistry, SpaceViewId, @@ -38,6 +37,11 @@ pub struct PerSpaceViewState { pub struct ViewportState { space_view_entity_window: SpaceViewEntityPicker, space_view_states: HashMap, + + /// Current candidate parent container for the ongoing drop. + /// + /// See [`ViewportState::is_candidate_drop_parent_container`] for details. + candidate_drop_parent_container_id: Option, } static DEFAULT_PROPS: Lazy = Lazy::::new(Default::default); @@ -64,6 +68,14 @@ impl ViewportState { .get(&space_view_id) .map_or(&DEFAULT_PROPS, |state| &state.auto_properties) } + + /// Is the provided container the current candidate parent container for the ongoing drag? + /// + /// When a drag is in progress, the candidate parent container for the dragged item should be highlighted. Note that + /// this can happen when hovering said container, its direct children, or even the item just after it. + pub fn is_candidate_drop_parent_container(&self, container_id: &ContainerId) -> bool { + self.candidate_drop_parent_container_id.as_ref() == Some(container_id) + } } /// Mutation actions to perform on the tree at the end of the frame. These messages are sent by the mutation APIs from @@ -87,6 +99,19 @@ pub enum TreeAction { /// Simplify the container with the provided options SimplifyContainer(ContainerId, egui_tiles::SimplificationOptions), + + /// Move some contents to a different container + MoveContents { + contents_to_move: Contents, + target_container: ContainerId, + target_position_in_container: usize, + }, + + /// Set the container that is currently identified as the drop target of an ongoing drag. + /// + /// This is used for highlighting the drop target in the UI. Note that the drop target container is reset at every + /// frame, so this command must be re-sent every frame as long as a drop target is identified. + SetDropTarget(ContainerId), } fn tree_simplification_option_for_app_options( @@ -318,6 +343,9 @@ impl<'a, 'b> Viewport<'a, 'b> { let mut reset = false; + // always reset the drop target + self.state.candidate_drop_parent_container_id = None; + // TODO(#4687): Be extra careful here. If we mark edited inappropriately we can create an infinite edit loop. for tree_action in self.tree_action_receiver.try_iter() { match tree_action { @@ -398,7 +426,7 @@ impl<'a, 'b> Viewport<'a, 'b> { self.tree_edited = true; } TreeAction::RemoveContents(contents) => { - let tile_id = contents.to_tile_id(); + let tile_id = contents.as_tile_id(); for tile in self.tree.remove_recursively(tile_id) { re_log::trace!("Removing tile {tile_id:?}"); @@ -420,6 +448,30 @@ impl<'a, 'b> Viewport<'a, 'b> { self.tree.simplify_children_of_tile(tile_id, &options); self.tree_edited = true; } + TreeAction::MoveContents { + contents_to_move, + target_container, + target_position_in_container, + } => { + re_log::trace!( + "Moving {contents_to_move:?} to container {target_container:?} at pos \ + {target_position_in_container}" + ); + + let contents_tile_id = contents_to_move.as_tile_id(); + let target_container_tile_id = blueprint_id_to_tile_id(&target_container); + + self.tree.move_tile_to_container( + contents_tile_id, + target_container_tile_id, + target_position_in_container, + true, + ); + self.tree_edited = true; + } + TreeAction::SetDropTarget(container_id) => { + self.state.candidate_drop_parent_container_id = Some(container_id); + } } } diff --git a/crates/re_viewport/src/viewport_blueprint.rs b/crates/re_viewport/src/viewport_blueprint.rs index 6aedc1a4d90e..2cc225ba507b 100644 --- a/crates/re_viewport/src/viewport_blueprint.rs +++ b/crates/re_viewport/src/viewport_blueprint.rs @@ -305,6 +305,105 @@ impl ViewportBlueprint { new_ids } + /// Given a predicate, finds the (first) matching contents by recursively walking from the root + /// container. + pub fn find_contents_by(&self, predicate: &impl Fn(&Contents) -> bool) -> Option { + if let Some(root_container) = self.root_container { + self.find_contents_in_container_by(predicate, &root_container) + } else { + None + } + } + + /// Given a predicate, finds the (first) matching contents by recursively walking from the given + /// container. + pub fn find_contents_in_container_by( + &self, + predicate: &impl Fn(&Contents) -> bool, + container_id: &ContainerId, + ) -> Option { + if predicate(&Contents::Container(*container_id)) { + return Some(Contents::Container(*container_id)); + } + + let Some(container) = self.container(container_id) else { + return None; + }; + + for contents in &container.contents { + if predicate(contents) { + return Some(*contents); + } + + match contents { + Contents::Container(container_id) => { + let res = self.find_contents_in_container_by(predicate, container_id); + if res.is_some() { + return res; + } + } + Contents::SpaceView(_) => {} + } + } + + None + } + + /// Checks if some content is (directly or indirectly) contained in the given container. + pub fn is_contents_in_container( + &self, + contents: &Contents, + container_id: &ContainerId, + ) -> bool { + self.find_contents_in_container_by(&|c| c == contents, container_id) + .is_some() + } + + /// Given a container or a space view, find its enclosing container and its position within it. + pub fn find_parent_and_position_index( + &self, + contents: &Contents, + ) -> Option<(ContainerId, usize)> { + if let Some(container_id) = self.root_container { + if *contents == Contents::Container(container_id) { + // root doesn't have a parent + return None; + } + self.find_parent_and_position_index_impl(contents, &container_id) + } else { + None + } + } + + fn find_parent_and_position_index_impl( + &self, + contents: &Contents, + container_id: &ContainerId, + ) -> Option<(ContainerId, usize)> { + let Some(container) = self.container(container_id) else { + return None; + }; + + for (pos, child_contents) in container.contents.iter().enumerate() { + if child_contents == contents { + return Some((*container_id, pos)); + } + + match child_contents { + Contents::Container(child_container_id) => { + let res = + self.find_parent_and_position_index_impl(contents, child_container_id); + if res.is_some() { + return res; + } + } + Contents::SpaceView(_) => {} + } + } + + None + } + /// Add a container of the provided kind. /// /// The container is added to the root container or, if provided, to the given parent container. @@ -321,6 +420,20 @@ impl ViewportBlueprint { self.send_tree_action(TreeAction::RemoveContents(contents)); } + /// Move the `contents` container or space view to the specified target container and position. + pub fn move_contents( + &self, + contents: Contents, + target_container: ContainerId, + target_position_in_container: usize, + ) { + self.send_tree_action(TreeAction::MoveContents { + contents_to_move: contents, + target_container, + target_position_in_container, + }); + } + /// Make sure the tab corresponding to this space view is focused. pub fn focus_tab(&self, space_view_id: SpaceViewId) { self.send_tree_action(TreeAction::FocusTab(space_view_id)); @@ -350,6 +463,14 @@ impl ViewportBlueprint { )); } + /// Set the container that is currently identified as the drop target of an ongoing drag. + /// + /// This is used for highlighting the drop target in the UI. Note that the drop target container is reset at every + /// frame, so this command must be re-sent every frame as long as a drop target is identified. + pub fn set_drop_target(&self, container_id: &ContainerId) { + self.send_tree_action(TreeAction::SetDropTarget(*container_id)); + } + #[allow(clippy::unused_self)] pub fn space_views_containing_entity_path( &self, diff --git a/crates/re_viewport/src/viewport_blueprint_ui.rs b/crates/re_viewport/src/viewport_blueprint_ui.rs index 2b1b01bbeb0d..5e46404378ee 100644 --- a/crates/re_viewport/src/viewport_blueprint_ui.rs +++ b/crates/re_viewport/src/viewport_blueprint_ui.rs @@ -5,8 +5,7 @@ use re_data_ui::item_ui; use re_entity_db::InstancePath; use re_log_types::{EntityPath, EntityPathRule}; use re_space_view::DataQueryBlueprint; -use re_ui::list_item::ListItem; -use re_ui::ReUi; +use re_ui::{drag_and_drop::DropTarget, list_item::ListItem, ReUi}; use re_viewer_context::{ ContainerId, DataQueryResult, DataResultNode, HoverHighlight, Item, SpaceViewId, ViewerContext, }; @@ -30,13 +29,19 @@ impl Viewport<'_, '_> { self.contents_ui(ctx, ui, &Contents::Container(root_container), true); } + let empty_space_response = + ui.allocate_response(ui.available_size(), egui::Sense::click()); + // clear selection upon clicking on empty space - if ui - .allocate_response(ui.available_size(), egui::Sense::click()) - .clicked() - { + if empty_space_response.clicked() { ctx.selection_state().clear_current(); } + + // handle drag and drop interaction on empty space + self.handle_empty_space_drag_and_drop_interaction( + ui, + empty_space_response.rect, + ); }); }); } @@ -85,12 +90,17 @@ impl Viewport<'_, '_> { let default_open = true; - let response = ListItem::new( + let re_ui::list_item::ShowCollapsingResponse { + item_response: response, + body_response, + } = ListItem::new( ctx.re_ui, format!("{:?}", container_blueprint.container_kind), ) .subdued(!container_visible) .selected(ctx.selection().contains_item(&item)) + .draggable(true) + .drop_target_style(self.state.is_candidate_drop_parent_container(container_id)) .with_icon(crate::icon_for_container_kind( &container_blueprint.container_kind, )) @@ -107,11 +117,18 @@ impl Viewport<'_, '_> { for child in &container_blueprint.contents { self.contents_ui(ctx, ui, child, container_visible); } - }) - .item_response; + }); item_ui::select_hovered_on_click(ctx, &response, item); + self.handle_drag_and_drop_interaction( + ctx, + ui, + Contents::Container(*container_id), + &response, + body_response.as_ref().map(|r| &r.response), + ); + if remove { self.blueprint.mark_user_interaction(ctx); self.blueprint @@ -164,10 +181,14 @@ impl Viewport<'_, '_> { let space_view_name = space_view.display_name_or_default(); - let response = ListItem::new(ctx.re_ui, space_view_name.as_ref()) + let re_ui::list_item::ShowCollapsingResponse { + item_response: mut response, + body_response, + } = ListItem::new(ctx.re_ui, space_view_name.as_ref()) .label_style(space_view_name.style()) .with_icon(space_view.class(ctx.space_view_class_registry).icon()) .selected(ctx.selection().contains_item(&item)) + .draggable(true) .subdued(!space_view_visible) .force_hovered(is_item_hovered) .with_buttons(|re_ui, ui| { @@ -199,9 +220,9 @@ impl Viewport<'_, '_> { } else { ui.label("No results"); } - }) - .item_response - .on_hover_text("Space View"); + }); + + response = response.on_hover_text("Space View"); if response.clicked() { self.blueprint.focus_tab(space_view.id); @@ -209,6 +230,14 @@ impl Viewport<'_, '_> { item_ui::select_hovered_on_click(ctx, &response, item); + self.handle_drag_and_drop_interaction( + ctx, + ui, + Contents::SpaceView(*space_view_id), + &response, + body_response.as_ref().map(|r| &r.response), + ); + if visibility_changed { if self.blueprint.auto_layout { re_log::trace!("Space view visibility changed - will no longer auto-layout"); @@ -484,6 +513,161 @@ impl Viewport<'_, '_> { .response .on_hover_text("Add new Space View"); } + + // ---------------------------------------------------------------------------- + // drag and drop support + + fn handle_drag_and_drop_interaction( + &self, + ctx: &ViewerContext<'_>, + ui: &egui::Ui, + contents: Contents, + response: &egui::Response, + body_response: Option<&egui::Response>, + ) { + // + // initiate drag and force single-selection + // + + if response.drag_started() { + ctx.selection_state().set_selection(contents.as_item()); + egui::DragAndDrop::set_payload(ui.ctx(), contents); + } + + // + // check if a drag is in progress and set the cursor accordingly + // + + let Some(dragged_item_id) = egui::DragAndDrop::payload(ui.ctx()).map(|payload| *payload) + else { + // nothing is being dragged, so nothing to do + return; + }; + + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + + // + // find our parent, our position within parent, and the previous container (if any) + // + + let Some((parent_container_id, pos_in_parent)) = + self.blueprint.find_parent_and_position_index(&contents) + else { + return; + }; + + let previous_container = if pos_in_parent > 0 { + self.blueprint + .container(&parent_container_id) + .map(|container| container.contents[pos_in_parent - 1]) + .filter(|contents| matches!(contents, Contents::Container(_))) + } else { + None + }; + + // + // find the drop target + // + + // Prepare the item description structure needed by `find_drop_target`. Here, we use + // `Contents` for the "ItemId" generic type parameter. + let item_desc = re_ui::drag_and_drop::ItemContext { + id: contents, + is_container: matches!(contents, Contents::Container(_)), + parent_id: Contents::Container(parent_container_id), + position_index_in_parent: pos_in_parent, + previous_container_id: previous_container, + }; + + let drop_target = re_ui::drag_and_drop::find_drop_target( + ui, + &item_desc, + response.rect, + body_response.map(|r| r.rect), + ReUi::list_item_height(), + ); + + if let Some(drop_target) = drop_target { + self.handle_drop_target(ui, dragged_item_id, &drop_target); + } + } + + fn handle_empty_space_drag_and_drop_interaction(&self, ui: &egui::Ui, empty_space: egui::Rect) { + // + // check if a drag is in progress and set the cursor accordingly + // + + let Some(dragged_item_id) = egui::DragAndDrop::payload(ui.ctx()).map(|payload| *payload) + else { + // nothing is being dragged, so nothing to do + return; + }; + + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + + // + // prepare a drop target corresponding to "insert last in root container" + // + // TODO(ab): this is a rather primitive behavior. Ideally we should allow dropping in the last container based + // on the horizontal position of the cursor. + + let Some(root_container_id) = self.blueprint.root_container else { + return; + }; + + if ui.rect_contains_pointer(empty_space) { + let drop_target = re_ui::drag_and_drop::DropTarget::new( + // TODO(#4909): this indent is a visual hack that should be remove once #4909 is done + (empty_space.left() + ui.spacing().indent..=empty_space.right()).into(), + empty_space.top(), + Contents::Container(root_container_id), + usize::MAX, + ); + + self.handle_drop_target(ui, dragged_item_id, &drop_target); + } + } + + fn handle_drop_target( + &self, + ui: &Ui, + dragged_item_id: Contents, + drop_target: &DropTarget, + ) { + // We cannot allow the target location to be "inside" the dragged item, because that would amount moving + // myself inside of me. + if let Contents::Container(dragged_container_id) = &dragged_item_id { + if self + .blueprint + .is_contents_in_container(&drop_target.target_parent_id, dragged_container_id) + { + return; + } + } + + ui.painter().hline( + drop_target.indicator_span_x, + drop_target.indicator_position_y, + (2.0, egui::Color32::WHITE), + ); + + let Contents::Container(target_container_id) = drop_target.target_parent_id else { + // this shouldn't append + return; + }; + + if ui.input(|i| i.pointer.any_released()) { + self.blueprint.move_contents( + dragged_item_id, + target_container_id, + drop_target.target_position_index, + ); + + egui::DragAndDrop::clear_payload(ui.ctx()); + } else { + self.blueprint.set_drop_target(&target_container_id); + } + } } // ----------------------------------------------------------------------------