diff --git a/Cargo.lock b/Cargo.lock index f88634fd456..8d2fee7098a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2020,6 +2020,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hierarchical_list_drag_and_drop" +version = "0.1.0" +dependencies = [ + "eframe", + "env_logger", + "rand", +] + [[package]] name = "home" version = "0.5.5" diff --git a/examples/hierachical_list_drag_and_drop/Cargo.toml b/examples/hierachical_list_drag_and_drop/Cargo.toml new file mode 100644 index 00000000000..df00574fb16 --- /dev/null +++ b/examples/hierachical_list_drag_and_drop/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "hierarchical_list_drag_and_drop" +version = "0.1.0" +authors = ["Antoine Beyeler ", "Emil Ernerfeldt "] +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.72" +publish = false + + +[dependencies] +eframe = { path = "../../crates/eframe", features = [ + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO +] } + + +env_logger = { version = "0.10", default-features = false, features = [ + "auto-color", + "humantime", +] } + +rand = "0.8.5" diff --git a/examples/hierachical_list_drag_and_drop/README.md b/examples/hierachical_list_drag_and_drop/README.md new file mode 100644 index 00000000000..a2ce81d6820 --- /dev/null +++ b/examples/hierachical_list_drag_and_drop/README.md @@ -0,0 +1,7 @@ +Example showing how to implement drag-and-drop in a hierarchical list + +```sh +cargo run -p hierarchical_drag_and_drop +``` + +![](screenshot.png) \ No newline at end of file diff --git a/examples/hierachical_list_drag_and_drop/screenshot.png b/examples/hierachical_list_drag_and_drop/screenshot.png new file mode 100644 index 00000000000..b48709e9775 Binary files /dev/null and b/examples/hierachical_list_drag_and_drop/screenshot.png differ diff --git a/examples/hierachical_list_drag_and_drop/src/app.rs b/examples/hierachical_list_drag_and_drop/src/app.rs new file mode 100644 index 00000000000..c2b44d3cd68 --- /dev/null +++ b/examples/hierachical_list_drag_and_drop/src/app.rs @@ -0,0 +1,463 @@ +//! This demo is a stripped-down version of the drag-and-drop implementation in the +//! [rerun viewer](https://github.com/rerun-io/rerun). + +use std::collections::HashMap; + +use eframe::{egui, egui::NumExt as _}; + +#[derive(Hash, Clone, Copy, PartialEq, Eq)] +struct ItemId(u32); + +impl ItemId { + fn new() -> Self { + Self(rand::random()) + } +} + +impl std::fmt::Debug for ItemId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{:04x}", self.0) + } +} + +impl From for egui::Id { + fn from(id: ItemId) -> Self { + Self::new(id) + } +} + +enum Item { + Container(Vec), + Leaf(String), +} + +#[derive(Debug)] +enum Command { + /// Set the selected item + SetSelectedItem(Option), + + /// Move the currently dragged item to the given container and position. + MoveItem { + moved_item_id: ItemId, + target_container_id: ItemId, + target_position_index: usize, + }, + + /// Specify the currently identified target container to be highlighted. + HighlightTargetContainer(ItemId), +} + +pub struct HierarchicalDragAndDrop { + /// All items + items: HashMap, + + /// Id of the root item (not displayed in the UI) + root_id: ItemId, + + /// Selected item, if any + selected_item: Option, + + /// If a drag is ongoing, this is the id of the destination container (if any was identified) + /// + /// This is used to highlight the target container. + target_container: Option, + + /// Channel to receive commands from the UI + command_receiver: std::sync::mpsc::Receiver, + + /// Channel to send commands from the UI + command_sender: std::sync::mpsc::Sender, +} + +impl Default for HierarchicalDragAndDrop { + fn default() -> Self { + let root_item = Item::Container(Vec::new()); + let root_id = ItemId::new(); + + let (command_sender, command_receiver) = std::sync::mpsc::channel(); + + let mut res = Self { + items: std::iter::once((root_id, root_item)).collect(), + root_id, + selected_item: None, + target_container: None, + command_receiver, + command_sender, + }; + + res.populate(); + + res + } +} + +impl eframe::App for HierarchicalDragAndDrop { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + self.ui(ui); + }); + } +} + +// +// Data stuff +// +impl HierarchicalDragAndDrop { + /// Add a bunch of items in the hierarchy. + fn populate(&mut self) { + let c1 = self.add_container(self.root_id); + let c2 = self.add_container(self.root_id); + let c3 = self.add_container(self.root_id); + self.add_leaf(self.root_id); + self.add_leaf(self.root_id); + + let c11 = self.add_container(c1); + let c12 = self.add_container(c1); + self.add_leaf(c11); + self.add_leaf(c11); + self.add_leaf(c12); + self.add_leaf(c12); + + self.add_leaf(c2); + self.add_leaf(c2); + + self.add_leaf(c3); + } + + fn container(&self, id: ItemId) -> Option<&Vec> { + match self.items.get(&id) { + Some(Item::Container(children)) => Some(children), + _ => None, + } + } + + /// Does some container contain the given item? + /// + /// Used to test if a target location is suitable for a given dragged item. + fn contains(&self, container_id: ItemId, item_id: ItemId) -> bool { + if let Some(children) = self.container(container_id) { + if container_id == item_id { + return true; + } + + if children.contains(&item_id) { + return true; + } + + for child_id in children { + if self.contains(*child_id, item_id) { + return true; + } + } + + return false; + } + + false + } + + /// Move item `item_id` to `container_id` at position `pos`. + fn move_item(&mut self, item_id: ItemId, container_id: ItemId, mut pos: usize) { + println!("Moving {item_id:?} to {container_id:?} at position {pos:?}"); + + // Remove the item from its current location. Note: we must adjust the target position if the item is + // moved within the same container, as the removal might shift the positions by one. + if let Some((source_parent_id, source_pos)) = self.parent_and_pos(item_id) { + if let Some(Item::Container(children)) = self.items.get_mut(&source_parent_id) { + children.remove(source_pos); + } + + if source_parent_id == container_id && source_pos < pos { + pos -= 1; + } + } + + if let Some(Item::Container(children)) = self.items.get_mut(&container_id) { + children.insert(pos.at_most(children.len()), item_id); + } + } + + /// Find the parent of an item, and the index of that item within the parent's children. + fn parent_and_pos(&self, id: ItemId) -> Option<(ItemId, usize)> { + if id == self.root_id { + None + } else { + self.parent_and_pos_impl(id, self.root_id) + } + } + + fn parent_and_pos_impl(&self, id: ItemId, container_id: ItemId) -> Option<(ItemId, usize)> { + if let Some(children) = self.container(container_id) { + for (idx, child_id) in children.iter().enumerate() { + if child_id == &id { + return Some((container_id, idx)); + } else if self.container(*child_id).is_some() { + let res = self.parent_and_pos_impl(id, *child_id); + if res.is_some() { + return res; + } + } + } + } + + None + } + + fn add_container(&mut self, parent_id: ItemId) -> ItemId { + let id = ItemId::new(); + let item = Item::Container(Vec::new()); + + self.items.insert(id, item); + + if let Some(Item::Container(children)) = self.items.get_mut(&parent_id) { + children.push(id); + } + + id + } + + fn add_leaf(&mut self, parent_id: ItemId) { + let id = ItemId::new(); + let item = Item::Leaf(format!("Item {id:?}")); + + self.items.insert(id, item); + + if let Some(Item::Container(children)) = self.items.get_mut(&parent_id) { + children.push(id); + } + } + + fn send_command(&self, command: Command) { + // The only way this can fail is if the receiver has been dropped. + self.command_sender.send(command).ok(); + } +} + +// +// UI stuff +// +impl HierarchicalDragAndDrop { + pub fn ui(&mut self, ui: &mut egui::Ui) { + if let Some(top_level_items) = self.container(self.root_id) { + self.container_children_ui(ui, top_level_items); + } + + // deselect by clicking in the empty space + if ui + .interact( + ui.available_rect_before_wrap(), + "empty_space".into(), + egui::Sense::click(), + ) + .clicked() + { + self.send_command(Command::SetSelectedItem(None)); + } + + // always reset the target container + self.target_container = None; + + while let Ok(command) = self.command_receiver.try_recv() { + println!("Received command: {command:?}"); + match command { + Command::SetSelectedItem(item_id) => self.selected_item = item_id, + Command::MoveItem { + moved_item_id, + target_container_id, + target_position_index, + } => self.move_item(moved_item_id, target_container_id, target_position_index), + Command::HighlightTargetContainer(item_id) => { + self.target_container = Some(item_id); + } + } + } + } + + fn container_ui(&self, ui: &mut egui::Ui, item_id: ItemId, children: &Vec) { + let (response, head_response, body_resp) = + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + item_id.into(), + true, + ) + .show_header(ui, |ui| { + ui.add( + egui::Label::new(format!("Container {item_id:?}")) + .selectable(false) + .sense(egui::Sense::click_and_drag()), + ) + }) + .body(|ui| { + self.container_children_ui(ui, children); + }); + + if head_response.inner.clicked() { + self.send_command(Command::SetSelectedItem(Some(item_id))); + } + + if self.target_container == Some(item_id) { + ui.painter().rect_stroke( + head_response.inner.rect, + 2.0, + (1.0, ui.visuals().selection.bg_fill), + ); + } + + self.handle_drag_and_drop_interaction( + ui, + item_id, + true, + &head_response.inner.union(response), + body_resp.as_ref().map(|r| &r.response), + ); + } + + fn container_children_ui(&self, ui: &mut egui::Ui, children: &Vec) { + for child_id in children { + // check if the item is selected + ui.visuals_mut().override_text_color = if Some(*child_id) == self.selected_item { + Some(ui.visuals().selection.bg_fill) + } else { + None + }; + + match self.items.get(child_id) { + Some(Item::Container(children)) => { + self.container_ui(ui, *child_id, children); + } + Some(Item::Leaf(label)) => { + self.leaf_ui(ui, *child_id, label); + } + None => {} + } + } + } + + fn leaf_ui(&self, ui: &mut egui::Ui, item_id: ItemId, label: &str) { + let response = ui.add( + egui::Label::new(label) + .selectable(false) + .sense(egui::Sense::click_and_drag()), + ); + + if response.clicked() { + self.send_command(Command::SetSelectedItem(Some(item_id))); + } + + self.handle_drag_and_drop_interaction(ui, item_id, false, &response, None); + } + + fn handle_drag_and_drop_interaction( + &self, + ui: &egui::Ui, + item_id: ItemId, + is_container: bool, + response: &egui::Response, + body_response: Option<&egui::Response>, + ) { + // + // handle start of drag + // + + if response.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), item_id); + + // force selection to the dragged item + self.send_command(Command::SetSelectedItem(Some(item_id))); + } + + // + // handle candidate drop + // + + // 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; + }; + + 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; + }; + + 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 item_desc = crate::drag_and_drop::DropItemDescription { + id: item_id, + is_container, + parent_id, + position_index_in_parent, + previous_container_id, + }; + + // + // compute the drag target areas based on the item and body responses + // + + // adjust the drop target to account for the spacing between items + let item_rect = response + .rect + .expand2(egui::Vec2::new(0.0, ui.spacing().item_spacing.y / 2.0)); + let body_rect = body_response.map(|r| { + r.rect + .expand2(egui::Vec2::new(0.0, ui.spacing().item_spacing.y)) + }); + + // + // find the candidate drop target + // + + let drop_target = crate::drag_and_drop::find_drop_target( + ui, + &item_desc, + item_rect, + body_rect, + response.rect.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, drop_target.target_parent_id) { + return; + } + + // extend the cursor to the right of the enclosing container + let mut span_x = drop_target.indicator_span_x; + span_x.max = ui.cursor().right(); + + ui.painter().hline( + span_x, + drop_target.indicator_position_y, + (2.0, egui::Color32::BLACK), + ); + + // 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: 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( + drop_target.target_parent_id, + )); + } + } + } +} diff --git a/examples/hierachical_list_drag_and_drop/src/drag_and_drop.rs b/examples/hierachical_list_drag_and_drop/src/drag_and_drop.rs new file mode 100644 index 00000000000..217ee2a6e28 --- /dev/null +++ b/examples/hierachical_list_drag_and_drop/src/drag_and_drop.rs @@ -0,0 +1,311 @@ +//! Helpers for drag and drop support. Works well in combination with [`crate::list_item::ListItem`]. + +use eframe::egui; + +/// Context information related to a candidate drop target, used by [`find_drop_target`] to compute the [`DropTarget`], +/// if any. +pub struct DropItemDescription { + /// 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 if 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 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_desc: &DropItemDescription, + item_rect: egui::Rect, + body_rect: Option, + item_height: f32, +) -> Option> { + let indent = ui.spacing().indent; + let item_id = item_desc.id; + let is_container = item_desc.is_container; + let parent_id = item_desc.parent_id; + let pos_in_parent = item_desc.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_desc.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::BLUE.gamma_multiply(0.5), + "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_desc.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(non_empty_body_rect) = non_empty_body_rect { + non_empty_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(non_empty_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( + (non_empty_body_rect.left() + indent..=non_empty_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/examples/hierachical_list_drag_and_drop/src/main.rs b/examples/hierachical_list_drag_and_drop/src/main.rs new file mode 100644 index 00000000000..f7071ab0e81 --- /dev/null +++ b/examples/hierachical_list_drag_and_drop/src/main.rs @@ -0,0 +1,20 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +mod app; +mod drag_and_drop; + +use crate::app::HierarchicalDragAndDrop; +use eframe::egui; + +fn main() -> Result<(), eframe::Error> { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]), + ..Default::default() + }; + eframe::run_native( + "My egui App", + options, + Box::new(|_cc| Box::::default()), + ) +}