diff --git a/Cargo.lock b/Cargo.lock index b68fcb506009..6e761715e4ed 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 6fe28ae5b443..2a15b7ab66ec 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 d42c5b537e00..0ba38e876213 100644 --- a/crates/re_viewer/src/ui/selection_panel.rs +++ b/crates/re_viewer/src/ui/selection_panel.rs @@ -780,7 +780,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 cad80df88867..8bc9e19777f1 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, }; @@ -31,13 +30,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, + ); }); }); } @@ -86,12 +91,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, )) @@ -108,11 +118,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 @@ -165,10 +182,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| { @@ -200,9 +221,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); @@ -210,6 +231,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"); @@ -491,6 +520,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); + } + } } // ----------------------------------------------------------------------------