From cae926a2b04a4dff710b1cf4622b81c08fabfe46 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Tue, 10 Dec 2024 17:30:53 +0100 Subject: [PATCH 01/16] WIP: enable dragging entities from the --- crates/viewer/re_time_panel/src/lib.rs | 3 +- crates/viewer/re_ui/src/design_tokens.rs | 11 ++ .../viewer/re_ui/src/list_item/list_item.rs | 2 +- .../viewer/re_viewer_context/src/contents.rs | 10 +- .../re_viewer_context/src/drag_and_drop.rs | 185 +++++++++++------- crates/viewer/re_viewport/src/viewport_ui.rs | 71 ++++++- 6 files changed, 188 insertions(+), 94 deletions(-) diff --git a/crates/viewer/re_time_panel/src/lib.rs b/crates/viewer/re_time_panel/src/lib.rs index 39e67101dc4d..dc04da490d8e 100644 --- a/crates/viewer/re_time_panel/src/lib.rs +++ b/crates/viewer/re_time_panel/src/lib.rs @@ -673,6 +673,7 @@ impl TimePanel { } = ui .list_item() .selected(is_selected) + .draggable(true) .force_hovered(is_item_hovered) .show_hierarchical_with_children( ui, @@ -726,7 +727,7 @@ impl TimePanel { &response, SelectionUpdateBehavior::UseSelection, ); - ctx.handle_select_hover_drag_interactions(&response, item.to_item(), false); + ctx.handle_select_hover_drag_interactions(&response, item.to_item(), true); let is_closed = body_response.is_none(); let response_rect = response.rect; diff --git a/crates/viewer/re_ui/src/design_tokens.rs b/crates/viewer/re_ui/src/design_tokens.rs index 50acbbd2e7e4..8d6a6f8a53f3 100644 --- a/crates/viewer/re_ui/src/design_tokens.rs +++ b/crates/viewer/re_ui/src/design_tokens.rs @@ -430,6 +430,17 @@ impl DesignTokens { pub fn thumbnail_background_color(&self) -> egui::Color32 { self.color(ColorToken::gray(S250)) } + + /// Stroke used to indicate that a UI element is a container that will receive a drag-and-drop + /// payload. + /// + /// Sometimes this is the UI element that is being dragged over (e.g., a view receiving a new + /// entity). Sometimes this is a UI element not under the pointer, but whose content is + /// being hovered (e.g., a container in the blueprint tree) + #[inline] + pub fn drop_target_container_stroke(&self) -> egui::Stroke { + egui::Stroke::new(1.0, self.color(ColorToken::blue(S350))) + } } // ---------------------------------------------------------------------------- diff --git a/crates/viewer/re_ui/src/list_item/list_item.rs b/crates/viewer/re_ui/src/list_item/list_item.rs index cdee8a6b3f06..275505a00659 100644 --- a/crates/viewer/re_ui/src/list_item/list_item.rs +++ b/crates/viewer/re_ui/src/list_item/list_item.rs @@ -389,7 +389,7 @@ impl ListItem { Shape::rect_stroke( bg_rect_to_paint.expand(-1.0), 0.0, - egui::Stroke::new(1.0, ui.visuals().selection.bg_fill), + crate::design_tokens().drop_target_container_stroke(), ), ); } diff --git a/crates/viewer/re_viewer_context/src/contents.rs b/crates/viewer/re_viewer_context/src/contents.rs index 38149f711a25..558fbe8ad941 100644 --- a/crates/viewer/re_viewer_context/src/contents.rs +++ b/crates/viewer/re_viewer_context/src/contents.rs @@ -5,7 +5,7 @@ use egui_tiles::TileId; use re_log_types::EntityPath; use crate::item::Item; -use crate::{BlueprintId, BlueprintIdRegistry, ContainerId, ItemCollection, ViewId}; +use crate::{BlueprintId, BlueprintIdRegistry, ContainerId, ViewId}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Contents { @@ -65,14 +65,6 @@ impl Contents { } } -impl TryFrom<&ItemCollection> for Vec { - type Error = (); - - fn try_from(value: &ItemCollection) -> Result { - value.iter().map(|(item, _)| item.try_into()).collect() - } -} - impl TryFrom for Contents { type Error = (); diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index bec7e7e43a5f..63bc95fff5bc 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -1,10 +1,11 @@ //! Implement a global drag-and-drop payload type that enable dragging from various parts of the UI //! (e.g., from the streams tree to the viewport, etc.). -use std::fmt::Formatter; +use std::fmt::{Display, Formatter}; use itertools::Itertools; - +use re_entity_db::InstancePath; +use re_log_types::EntityPath; use re_ui::{ ColorToken, Hue, Scale::{S325, S375}, @@ -19,35 +20,63 @@ pub enum DragAndDropPayload { /// The dragged content is made only of [`Contents`]. Contents { contents: Vec }, + /// The dragged content is made of entities. + Entities { entities: Vec }, + /// The dragged content is made of a collection of [`Item`]s we do know how to handle. Invalid, } impl DragAndDropPayload { pub fn from_items(selected_items: &ItemCollection) -> Self { - if let Ok(contents) = selected_items.try_into() { + if let Some(contents) = try_item_collection_to_contents(selected_items) { Self::Contents { contents } + } else if let Some(entities) = try_item_collection_to_entities(selected_items) { + Self::Entities { entities } } else { Self::Invalid } } } +fn try_item_collection_to_contents(items: &ItemCollection) -> Option> { + items.iter().map(|(item, _)| item.try_into().ok()).collect() +} + +fn try_item_collection_to_entities(items: &ItemCollection) -> Option> { + items + .iter() + .map(|(item, _)| match item { + Item::InstancePath(instance_path) | Item::DataResult(_, instance_path) => instance_path + .is_all() + .then(|| instance_path.entity_path.clone()), + _ => None, + }) + .collect() +} + impl std::fmt::Display for DragAndDropPayload { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut item_counter = ItemCounter::default(); + match self { - Self::Contents { contents } => items_to_string( - contents - .iter() - .map(|content| content.as_item()) - .collect_vec() - .iter(), - ) - .fmt(f), + Self::Contents { contents } => { + for content in contents { + item_counter.add(&content.as_item()); + } + } + + Self::Entities { entities } => { + for entity in entities { + item_counter.add(&Item::InstancePath(InstancePath::from(entity.clone()))); + } + } // this is not used in the UI - Self::Invalid => "invalid selection".fmt(f), + Self::Invalid => {} } + + item_counter.fmt(f) } } @@ -59,6 +88,7 @@ pub fn drag_and_drop_payload_cursor_ui(ctx: &egui::Context) { if let Some(pointer_pos) = ctx.pointer_interact_pos() { let icon = match payload.as_ref() { DragAndDropPayload::Contents { .. } => &re_ui::icons::DND_MOVE, + DragAndDropPayload::Entities { .. } => &re_ui::icons::DND_ADD_TO_EXISTING, // don't draw anything for invalid selection DragAndDropPayload::Invalid => return, }; @@ -76,21 +106,18 @@ pub fn drag_and_drop_payload_cursor_ui(ctx: &egui::Context) { ui.set_opacity(0.7); - let response = drag_pill_frame(matches!( - payload.as_ref(), - &DragAndDropPayload::Invalid { .. } - )) - .show(&mut ui, |ui| { - let text_color = ui.visuals().widgets.inactive.text_color(); + let response = drag_pill_frame() + .show(&mut ui, |ui| { + let text_color = ui.visuals().widgets.inactive.text_color(); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; - ui.small_icon(icon, Some(text_color)); - ui.label(egui::RichText::new(payload.to_string()).color(text_color)); - }); - }) - .response; + ui.small_icon(icon, Some(text_color)); + ui.label(egui::RichText::new(payload.to_string()).color(text_color)); + }); + }) + .response; let delta = pointer_pos - response.rect.right_bottom(); ctx.transform_layer_shapes(layer_id, emath::TSTransform::from_translation(delta)); @@ -98,8 +125,8 @@ pub fn drag_and_drop_payload_cursor_ui(ctx: &egui::Context) { } } -fn drag_pill_frame(error_state: bool) -> egui::Frame { - let hue = if error_state { Hue::Red } else { Hue::Blue }; +fn drag_pill_frame() -> egui::Frame { + let hue = Hue::Blue; egui::Frame { fill: re_ui::design_tokens().color(ColorToken::new(hue, S325)), @@ -117,62 +144,70 @@ fn drag_pill_frame(error_state: bool) -> egui::Frame { ..Default::default() } } +/// Helper class to count item types and display them in a human-readable way. +#[derive(Debug, Default)] +struct ItemCounter { + container_cnt: u32, + view_cnt: u32, + app_cnt: u32, + data_source_cnt: u32, + store_cnt: u32, + entity_cnt: u32, + instance_cnt: u32, + component_cnt: u32, +} -fn items_to_string<'a>(items: impl Iterator) -> String { - let mut container_cnt = 0u32; - let mut view_cnt = 0u32; - let mut app_cnt = 0u32; - let mut data_source_cnt = 0u32; - let mut store_cnt = 0u32; - let mut entity_cnt = 0u32; - let mut instance_cnt = 0u32; - let mut component_cnt = 0u32; - - for item in items { +impl ItemCounter { + fn add(&mut self, item: &Item) { match item { - Item::Container(_) => container_cnt += 1, - Item::View(_) => view_cnt += 1, - Item::AppId(_) => app_cnt += 1, - Item::DataSource(_) => data_source_cnt += 1, - Item::StoreId(_) => store_cnt += 1, + Item::Container(_) => self.container_cnt += 1, + Item::View(_) => self.view_cnt += 1, + Item::AppId(_) => self.app_cnt += 1, + Item::DataSource(_) => self.data_source_cnt += 1, + Item::StoreId(_) => self.store_cnt += 1, Item::InstancePath(instance_path) | Item::DataResult(_, instance_path) => { if instance_path.is_all() { - entity_cnt += 1; + self.entity_cnt += 1; } else { - instance_cnt += 1; + self.instance_cnt += 1; } } - Item::ComponentPath(_) => component_cnt += 1, + Item::ComponentPath(_) => self.component_cnt += 1, } } +} - let count_and_names = [ - (container_cnt, "container", "containers"), - (view_cnt, "view", "views"), - (app_cnt, "app", "apps"), - (data_source_cnt, "data source", "data sources"), - (store_cnt, "store", "stores"), - (entity_cnt, "entity", "entities"), - (instance_cnt, "instance", "instances"), - (component_cnt, "component", "components"), - ]; - - count_and_names - .into_iter() - .filter_map(|(count, name_singular, name_plural)| { - if count > 0 { - Some(format!( - "{} {}", - re_format::format_uint(count), - if count == 1 { - name_singular - } else { - name_plural - }, - )) - } else { - None - } - }) - .join(", ") +impl Display for ItemCounter { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let count_and_names = [ + (&self.container_cnt, "container", "containers"), + (&self.view_cnt, "view", "views"), + (&self.app_cnt, "app", "apps"), + (&self.data_source_cnt, "data source", "data sources"), + (&self.store_cnt, "store", "stores"), + (&self.entity_cnt, "entity", "entities"), + (&self.instance_cnt, "instance", "instances"), + (&self.component_cnt, "component", "components"), + ]; + + count_and_names + .into_iter() + .filter_map(|(&count, name_singular, name_plural)| { + if count > 0 { + Some(format!( + "{} {}", + re_format::format_uint(count), + if count == 1 { + name_singular + } else { + name_plural + }, + )) + } else { + None + } + }) + .join(", ") + .fmt(f) + } } diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 96382c59bd12..8b0d43749326 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -6,10 +6,12 @@ use ahash::HashMap; use egui_tiles::{Behavior as _, EditAction}; use re_context_menu::{context_menu_ui_for_item, SelectionUpdateBehavior}; -use re_ui::{ContextExt as _, DesignTokens, Icon, UiExt as _}; +use re_log_types::{EntityPath, EntityPathRule}; +use re_ui::{design_tokens, ContextExt as _, DesignTokens, Icon, UiExt as _}; use re_viewer_context::{ - blueprint_id_to_tile_id, icon_for_container_kind, Contents, Item, PublishedViewInfo, - SystemExecutionOutput, ViewClassRegistry, ViewId, ViewQuery, ViewStates, ViewerContext, + blueprint_id_to_tile_id, icon_for_container_kind, Contents, DragAndDropPayload, Item, + PublishedViewInfo, SystemExecutionOutput, ViewClassRegistry, ViewId, ViewQuery, ViewStates, + ViewerContext, }; use re_viewport_blueprint::{ViewportBlueprint, ViewportCommand}; @@ -100,6 +102,15 @@ impl ViewportUi { tree.ui(&mut egui_tiles_delegate, ui); + let dragged_payload = egui::DragAndDrop::payload::(ui.ctx()); + let dragged_payload = dragged_payload.as_ref().and_then(|payload| { + if let DragAndDropPayload::Entities { entities } = payload.as_ref() { + Some(entities) + } else { + None + } + }); + // Outline hovered & selected tiles: for contents in blueprint.contents_iter() { let tile_id = contents.as_tile_id(); @@ -117,7 +128,16 @@ impl ViewportUi { hovered = false; } - let stroke = if hovered { + let view_id = contents.as_view_id(); + + //TODO: add visualizabilty criteron here + let candidate_drop_of_entities_in_view = dragged_payload + .and_then(|payload| ui.rect_contains_pointer(rect).then_some(payload)) + .and_then(|payload| view_id.map(|view_id| (view_id, payload))); + + let stroke = if candidate_drop_of_entities_in_view.is_some() { + design_tokens().drop_target_container_stroke() + } else if hovered { ui.ctx().hover_stroke() } else if selected { ui.ctx().selection_stroke() @@ -125,14 +145,21 @@ impl ViewportUi { continue; }; + if let Some((view_id, entities)) = candidate_drop_of_entities_in_view { + self.handle_entities_drop(ctx, ui, view_id, entities); + } + // We want the rectangle to be on top of everything in the viewport, // including stuff in "zoom-pan areas", like we use in the graph view. - let top_layer_id = egui::LayerId::new(ui.layer_id().order, ui.id().with("child_id")); + let top_layer_id = + egui::LayerId::new(ui.layer_id().order, ui.id().with("child_id")); ui.ctx().set_sublayer(ui.layer_id(), top_layer_id); // Make sure it is directly on top of the ui layer // We need to shrink a bit so the panel-resize lines don't cover the highlight rectangle. // This is hacky. - ui.painter().clone().with_layer_id(top_layer_id) + ui.painter() + .clone() + .with_layer_id(top_layer_id) .rect_stroke(rect.shrink(stroke.width), 0.0, stroke); } } @@ -144,7 +171,8 @@ impl ViewportUi { if egui_tiles_delegate.edited || is_dragging_a_tile { if blueprint.auto_layout() { re_log::trace!( - "The user is manipulating the egui_tiles tree - will no longer auto-layout" + "The user is manipulating the egui_tiles tree - will no longer \ + auto-layout" ); } @@ -164,7 +192,10 @@ impl ViewportUi { }); } - self.blueprint.deferred_commands.lock().push(ViewportCommand::SetTree(tree)); + self.blueprint + .deferred_commands + .lock() + .push(ViewportCommand::SetTree(tree)); } } }); @@ -172,6 +203,30 @@ impl ViewportUi { self.blueprint.set_maximized(maximized, ctx); } + fn handle_entities_drop( + &self, + ctx: &ViewerContext<'_>, + ui: &mut egui::Ui, + view_id: ViewId, + entities: &[EntityPath], + ) { + if !ui.input(|i| i.pointer.any_released()) { + return; + } + + egui::DragAndDrop::clear_payload(ui.ctx()); + + let Some(view) = self.blueprint.view(&view_id) else { + return; + }; + + for entity in entities { + //TODO: do that only visualizable + view.contents + .raw_add_entity_inclusion(ctx, EntityPathRule::including_subtree(entity.clone())); + } + } + pub fn on_frame_start(&self, ctx: &ViewerContext<'_>) { re_tracing::profile_function!(); From 9ed096f0a68f41485c42c5ad81fcb9f7d1ae02a6 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 11 Dec 2024 17:28:24 +0100 Subject: [PATCH 02/16] Fix maximized view not getting highlighted --- crates/viewer/re_viewport/src/viewport_ui.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 8b0d43749326..d3454b22d78a 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -70,8 +70,12 @@ impl ViewportUi { let mut tree = if let Some(view_id) = blueprint.maximized { let mut tiles = egui_tiles::Tiles::default(); - let root = tiles.insert_pane(view_id); - egui_tiles::Tree::new("viewport_tree", root, tiles) + + // we must ensure that our temporary tree has the correct tile id, such that the tile id + // to view id logic later in this function works correctly + let tile_id = Contents::View(view_id).as_tile_id(); + tiles.insert(tile_id, egui_tiles::Tile::Pane(view_id)); + egui_tiles::Tree::new("viewport_tree", tile_id, tiles) } else { blueprint.tree.clone() }; From 56bb57f75ffe7fde1c37e705b1cd3ce6958f4374 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 11 Dec 2024 17:50:31 +0100 Subject: [PATCH 03/16] Dont unnecessarily pass a `ui` --- crates/viewer/re_viewport/src/viewport_ui.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index d3454b22d78a..83b9d6ff38b2 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -150,7 +150,7 @@ impl ViewportUi { }; if let Some((view_id, entities)) = candidate_drop_of_entities_in_view { - self.handle_entities_drop(ctx, ui, view_id, entities); + self.handle_entities_drop(ctx, view_id, entities); } // We want the rectangle to be on top of everything in the viewport, @@ -210,15 +210,14 @@ impl ViewportUi { fn handle_entities_drop( &self, ctx: &ViewerContext<'_>, - ui: &mut egui::Ui, view_id: ViewId, entities: &[EntityPath], ) { - if !ui.input(|i| i.pointer.any_released()) { + if !ctx.egui_ctx.input(|i| i.pointer.any_released()) { return; } - egui::DragAndDrop::clear_payload(ui.ctx()); + egui::DragAndDrop::clear_payload(ctx.egui_ctx); let Some(view) = self.blueprint.view(&view_id) else { return; From 6ddf6cfd72c2a8044a754b766485796b15313754 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 11 Dec 2024 17:53:47 +0100 Subject: [PATCH 04/16] Move the EntityAddInfo to re_viewport_blueprint to make it availalble to other crates --- .../src/view_entity_picker.rs | 119 +---------------- .../src/entity_add_info.rs | 121 ++++++++++++++++++ .../viewer/re_viewport_blueprint/src/lib.rs | 2 + 3 files changed, 127 insertions(+), 115 deletions(-) create mode 100644 crates/viewer/re_viewport_blueprint/src/entity_add_info.rs diff --git a/crates/viewer/re_selection_panel/src/view_entity_picker.rs b/crates/viewer/re_selection_panel/src/view_entity_picker.rs index ba1f814076de..7ee7cacae4f5 100644 --- a/crates/viewer/re_selection_panel/src/view_entity_picker.rs +++ b/crates/viewer/re_selection_panel/src/view_entity_picker.rs @@ -5,8 +5,10 @@ use re_data_ui::item_ui; use re_entity_db::{EntityPath, EntityTree, InstancePath}; use re_log_types::{EntityPathFilter, EntityPathRule}; use re_ui::UiExt as _; -use re_viewer_context::{DataQueryResult, ViewClassExt as _, ViewId, ViewerContext}; -use re_viewport_blueprint::{ViewBlueprint, ViewportBlueprint}; +use re_viewer_context::{DataQueryResult, ViewId, ViewerContext}; +use re_viewport_blueprint::{ + create_entity_add_info, CanAddToView, EntityAddInfo, ViewBlueprint, ViewportBlueprint, +}; /// Window for adding/removing entities from a view. /// @@ -245,116 +247,3 @@ fn add_entities_line_ui( }); }); } - -/// Describes if an entity path can be added to a view. -#[derive(Clone, PartialEq, Eq)] -enum CanAddToView { - Compatible { already_added: bool }, - No { reason: String }, -} - -impl Default for CanAddToView { - fn default() -> Self { - Self::Compatible { - already_added: false, - } - } -} - -impl CanAddToView { - /// Can be generally added but view might already have this element. - pub fn is_compatible(&self) -> bool { - match self { - Self::Compatible { .. } => true, - Self::No { .. } => false, - } - } - - /// Can be added and view doesn't have it already. - pub fn is_compatible_and_missing(&self) -> bool { - self == &Self::Compatible { - already_added: false, - } - } - - pub fn join(&self, other: &Self) -> Self { - match self { - Self::Compatible { already_added } => { - let already_added = if let Self::Compatible { - already_added: already_added_other, - } = other - { - *already_added && *already_added_other - } else { - *already_added - }; - Self::Compatible { already_added } - } - Self::No { .. } => other.clone(), - } - } -} - -#[derive(Default)] -#[allow(dead_code)] -struct EntityAddInfo { - can_add: CanAddToView, - can_add_self_or_descendant: CanAddToView, -} - -fn create_entity_add_info( - ctx: &ViewerContext<'_>, - tree: &EntityTree, - view: &ViewBlueprint, - query_result: &DataQueryResult, -) -> IntMap { - let mut meta_data: IntMap = IntMap::default(); - - // TODO(andreas): This should be state that is already available because it's part of the view's state. - let class = view.class(ctx.view_class_registry); - let visualizable_entities = class.determine_visualizable_entities( - ctx.applicable_entities_per_visualizer, - ctx.recording(), - &ctx.view_class_registry - .new_visualizer_collection(view.class_identifier()), - &view.space_origin, - ); - - tree.visit_children_recursively(|entity_path| { - let can_add: CanAddToView = - if visualizable_entities.iter().any(|(_, entities)| entities.contains(entity_path)) { - CanAddToView::Compatible { - already_added: query_result.contains_entity(entity_path), - } - } else { - // TODO(#6321): This shouldn't necessarily prevent us from adding it. - CanAddToView::No { - reason: format!( - "Entity can't be displayed by any of the available visualizers in this class of view ({}).", - view.class_identifier() - ), - } - }; - - if can_add.is_compatible() { - // Mark parents aware that there is some descendant that is compatible - let mut path = entity_path.clone(); - while let Some(parent) = path.parent() { - let data = meta_data.entry(parent.clone()).or_default(); - data.can_add_self_or_descendant = data.can_add_self_or_descendant.join(&can_add); - path = parent; - } - } - - let can_add_self_or_descendant = can_add.clone(); - meta_data.insert( - entity_path.clone(), - EntityAddInfo { - can_add, - can_add_self_or_descendant, - }, - ); - }); - - meta_data -} diff --git a/crates/viewer/re_viewport_blueprint/src/entity_add_info.rs b/crates/viewer/re_viewport_blueprint/src/entity_add_info.rs new file mode 100644 index 000000000000..7d4aa4825d8f --- /dev/null +++ b/crates/viewer/re_viewport_blueprint/src/entity_add_info.rs @@ -0,0 +1,121 @@ +//! Utilities for determining if an entity can be added to a view. + +use nohash_hasher::IntMap; + +use re_entity_db::EntityTree; +use re_log_types::EntityPath; +use re_viewer_context::{DataQueryResult, ViewClassExt as _, ViewerContext}; + +use crate::ViewBlueprint; + +/// Describes if an entity path can be added to a view. +#[derive(Clone, PartialEq, Eq)] +pub enum CanAddToView { + Compatible { already_added: bool }, + No { reason: String }, +} + +impl Default for CanAddToView { + fn default() -> Self { + Self::Compatible { + already_added: false, + } + } +} + +impl CanAddToView { + /// Can be generally added but view might already have this element. + pub fn is_compatible(&self) -> bool { + match self { + Self::Compatible { .. } => true, + Self::No { .. } => false, + } + } + + /// Can be added and view doesn't have it already. + pub fn is_compatible_and_missing(&self) -> bool { + self == &Self::Compatible { + already_added: false, + } + } + + pub fn join(&self, other: &Self) -> Self { + match self { + Self::Compatible { already_added } => { + let already_added = if let Self::Compatible { + already_added: already_added_other, + } = other + { + *already_added && *already_added_other + } else { + *already_added + }; + Self::Compatible { already_added } + } + Self::No { .. } => other.clone(), + } + } +} + +#[derive(Default)] +pub struct EntityAddInfo { + pub can_add: CanAddToView, + pub can_add_self_or_descendant: CanAddToView, +} + +pub fn create_entity_add_info( + ctx: &ViewerContext<'_>, + tree: &EntityTree, + view: &ViewBlueprint, + query_result: &DataQueryResult, +) -> IntMap { + let mut meta_data: IntMap = IntMap::default(); + + // TODO(andreas): This should be state that is already available because it's part of the view's state. + let class = view.class(ctx.view_class_registry); + let visualizable_entities = class.determine_visualizable_entities( + ctx.applicable_entities_per_visualizer, + ctx.recording(), + &ctx.view_class_registry + .new_visualizer_collection(view.class_identifier()), + &view.space_origin, + ); + + tree.visit_children_recursively(|entity_path| { + let can_add: CanAddToView = + if visualizable_entities.iter().any(|(_, entities)| entities.contains(entity_path)) { + CanAddToView::Compatible { + already_added: query_result.contains_entity(entity_path), + } + } else { + // TODO(#6321): This shouldn't necessarily prevent us from adding it. + CanAddToView::No { + reason: format!( + "Entity can't be displayed by any of the available visualizers in this class of view ({}).", + view.class_identifier() + ), + } + }; + + if can_add.is_compatible() { + // Mark parents aware that there is some descendant that is compatible + let mut path = entity_path.clone(); + while let Some(parent) = path.parent() { + let data = meta_data.entry(parent.clone()).or_default(); + data.can_add_self_or_descendant = data.can_add_self_or_descendant.join(&can_add); + path = parent; + } + } + + let can_add_self_or_descendant = can_add.clone(); + meta_data.insert( + entity_path.clone(), + EntityAddInfo { + can_add, + can_add_self_or_descendant, + }, + ); + }); + + meta_data +} diff --git a/crates/viewer/re_viewport_blueprint/src/lib.rs b/crates/viewer/re_viewport_blueprint/src/lib.rs index 3ec02beb7356..a10abbcce1ea 100644 --- a/crates/viewer/re_viewport_blueprint/src/lib.rs +++ b/crates/viewer/re_viewport_blueprint/src/lib.rs @@ -3,6 +3,7 @@ //! This crate provides blueprint (i.e. description) for how to render the viewport. mod container; +mod entity_add_info; pub mod ui; mod view; mod view_contents; @@ -11,6 +12,7 @@ mod viewport_blueprint; mod viewport_command; pub use container::ContainerBlueprint; +pub use entity_add_info::{create_entity_add_info, CanAddToView, EntityAddInfo}; use re_viewer_context::ViewerContext; pub use view::ViewBlueprint; pub use view_contents::ViewContents; From 62f5f5b9d9fb2038cb612a964d412bb495047f3f Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 10:09:19 +0100 Subject: [PATCH 05/16] well, just don't... :shrug: --- crates/viewer/re_selection_panel/src/view_entity_picker.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/viewer/re_selection_panel/src/view_entity_picker.rs b/crates/viewer/re_selection_panel/src/view_entity_picker.rs index 7ee7cacae4f5..52e66f1dfcc8 100644 --- a/crates/viewer/re_selection_panel/src/view_entity_picker.rs +++ b/crates/viewer/re_selection_panel/src/view_entity_picker.rs @@ -58,10 +58,9 @@ fn add_entities_ui(ctx: &ViewerContext<'_>, ui: &mut egui::Ui, view: &ViewBluepr re_tracing::profile_function!(); let tree = &ctx.recording().tree(); - // TODO(jleibs): Avoid clone - let query_result = ctx.lookup_query_result(view.id).clone(); + let query_result = ctx.lookup_query_result(view.id); let entity_path_filter = &view.contents.entity_path_filter; - let entities_add_info = create_entity_add_info(ctx, tree, view, &query_result); + let entities_add_info = create_entity_add_info(ctx, tree, view, query_result); add_entities_tree_ui( ctx, @@ -69,7 +68,7 @@ fn add_entities_ui(ctx: &ViewerContext<'_>, ui: &mut egui::Ui, view: &ViewBluepr &tree.path.to_string(), tree, view, - &query_result, + query_result, entity_path_filter, &entities_add_info, ); From 16c3fe87f43445baf06d4d45029068fa918ce8e4 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 11:13:51 +0100 Subject: [PATCH 06/16] consider visualizability when highlighting target view and adding entities to filter --- crates/viewer/re_viewport/src/viewport_ui.rs | 92 ++++++++++++++------ 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 83b9d6ff38b2..7a051793a01f 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -13,7 +13,9 @@ use re_viewer_context::{ PublishedViewInfo, SystemExecutionOutput, ViewClassRegistry, ViewId, ViewQuery, ViewStates, ViewerContext, }; -use re_viewport_blueprint::{ViewportBlueprint, ViewportCommand}; +use re_viewport_blueprint::{ + create_entity_add_info, ViewBlueprint, ViewportBlueprint, ViewportCommand, +}; use crate::system_execution::{execute_systems_for_all_views, execute_systems_for_view}; @@ -132,14 +134,28 @@ impl ViewportUi { hovered = false; } - let view_id = contents.as_view_id(); + // Handle drag-and-drop if this is a view. + //TODO(#8428): simplify with let-chains + let should_display_drop_destination_frame = 'scope: { + if !ui.rect_contains_pointer(rect) { + break 'scope false; + } + + let Some(view_blueprint) = contents + .as_view_id() + .and_then(|view_id| self.blueprint.view(&view_id)) + else { + break 'scope false; + }; - //TODO: add visualizabilty criteron here - let candidate_drop_of_entities_in_view = dragged_payload - .and_then(|payload| ui.rect_contains_pointer(rect).then_some(payload)) - .and_then(|payload| view_id.map(|view_id| (view_id, payload))); + let Some(dragged_payload) = dragged_payload else { + break 'scope false; + }; - let stroke = if candidate_drop_of_entities_in_view.is_some() { + Self::handle_drop_entities_to_view(ctx, view_blueprint, dragged_payload) + }; + + let stroke = if should_display_drop_destination_frame { design_tokens().drop_target_container_stroke() } else if hovered { ui.ctx().hover_stroke() @@ -149,10 +165,6 @@ impl ViewportUi { continue; }; - if let Some((view_id, entities)) = candidate_drop_of_entities_in_view { - self.handle_entities_drop(ctx, view_id, entities); - } - // We want the rectangle to be on top of everything in the viewport, // including stuff in "zoom-pan areas", like we use in the graph view. let top_layer_id = @@ -207,26 +219,56 @@ impl ViewportUi { self.blueprint.set_maximized(maximized, ctx); } - fn handle_entities_drop( - &self, + /// Handle the entities being dragged over a view. + /// + /// Returns whether a "drop zone candidate" frame should be displayed to the user. + /// + /// Design decisions: + /// - We accept the drop only if at least one of the entities is visualizable and not already + /// included. + /// - When the drop happens, of all dropped entities, we only add those which are visualizable. + /// + fn handle_drop_entities_to_view( ctx: &ViewerContext<'_>, - view_id: ViewId, + view_blueprint: &ViewBlueprint, entities: &[EntityPath], - ) { - if !ctx.egui_ctx.input(|i| i.pointer.any_released()) { - return; + ) -> bool { + let add_info = create_entity_add_info( + ctx, + ctx.recording().tree(), + view_blueprint, + ctx.lookup_query_result(view_blueprint.id), + ); + + // check if any entity or its children are visualizable and not yet included in the view + let can_entity_be_added = |entity: &EntityPath| { + add_info + .get(entity) + .is_some_and(|info| info.can_add_self_or_descendant.is_compatible_and_missing()) + }; + + let any_is_visualizable = entities.iter().any(can_entity_be_added); + if !any_is_visualizable { + return false; } - egui::DragAndDrop::clear_payload(ctx.egui_ctx); + // drop incoming! + if ctx.egui_ctx.input(|i| i.pointer.any_released()) { + egui::DragAndDrop::clear_payload(ctx.egui_ctx); - let Some(view) = self.blueprint.view(&view_id) else { - return; - }; + for entity in entities { + if can_entity_be_added(entity) { + view_blueprint.contents.raw_add_entity_inclusion( + ctx, + EntityPathRule::including_subtree(entity.clone()), + ); + } + } - for entity in entities { - //TODO: do that only visualizable - view.contents - .raw_add_entity_inclusion(ctx, EntityPathRule::including_subtree(entity.clone())); + // drop is completed, no need for highlighting anymore + false + } else { + any_is_visualizable } } From 9df6e8b9bf03f97683008ef09598de32ee6ca1f4 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 11:30:44 +0100 Subject: [PATCH 07/16] lint --- crates/viewer/re_viewer_context/src/drag_and_drop.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index 63bc95fff5bc..8864e53760b5 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -144,6 +144,7 @@ fn drag_pill_frame() -> egui::Frame { ..Default::default() } } + /// Helper class to count item types and display them in a human-readable way. #[derive(Debug, Default)] struct ItemCounter { From b7f267babd505da3c1c93dae2f68118e208804ee Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 15:52:03 +0100 Subject: [PATCH 08/16] select view on successful drop + slightly dim view with blue color when they can accept a drop --- crates/viewer/re_viewport/src/viewport_ui.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 7a051793a01f..804f3e14a24e 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -173,10 +173,16 @@ impl ViewportUi { // We need to shrink a bit so the panel-resize lines don't cover the highlight rectangle. // This is hacky. - ui.painter() - .clone() - .with_layer_id(top_layer_id) - .rect_stroke(rect.shrink(stroke.width), 0.0, stroke); + let painter = ui.painter().clone().with_layer_id(top_layer_id); + painter.rect_stroke(rect.shrink(stroke.width), 0.0, stroke); + + if should_display_drop_destination_frame { + painter.rect_filled( + rect.shrink(stroke.width), + 0.0, + stroke.color.gamma_multiply(0.1), + ); + } } } @@ -265,6 +271,9 @@ impl ViewportUi { } } + ctx.selection_state() + .set_selection(Item::View(view_blueprint.id)); + // drop is completed, no need for highlighting anymore false } else { From 4539d4e6cac29d92611bb7f81c1947ccd2ecde7c Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 16:04:13 +0100 Subject: [PATCH 09/16] make drop target frame 2px wide (+ fix display bug in the blueprint tree) --- crates/viewer/re_ui/src/design_tokens.rs | 2 +- crates/viewer/re_ui/src/list_item/list_item.rs | 7 ++----- crates/viewer/re_ui/src/ui_ext.rs | 6 +++++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/viewer/re_ui/src/design_tokens.rs b/crates/viewer/re_ui/src/design_tokens.rs index 8d6a6f8a53f3..125e64989d93 100644 --- a/crates/viewer/re_ui/src/design_tokens.rs +++ b/crates/viewer/re_ui/src/design_tokens.rs @@ -439,7 +439,7 @@ impl DesignTokens { /// being hovered (e.g., a container in the blueprint tree) #[inline] pub fn drop_target_container_stroke(&self) -> egui::Stroke { - egui::Stroke::new(1.0, self.color(ColorToken::blue(S350))) + egui::Stroke::new(2.0, self.color(ColorToken::blue(S350))) } } diff --git a/crates/viewer/re_ui/src/list_item/list_item.rs b/crates/viewer/re_ui/src/list_item/list_item.rs index 275505a00659..721f740851ac 100644 --- a/crates/viewer/re_ui/src/list_item/list_item.rs +++ b/crates/viewer/re_ui/src/list_item/list_item.rs @@ -384,13 +384,10 @@ impl ListItem { let bg_rect_to_paint = ui.painter().round_rect_to_pixels(bg_rect); if drag_target { + let stroke = crate::design_tokens().drop_target_container_stroke(); ui.painter().set( background_frame, - Shape::rect_stroke( - bg_rect_to_paint.expand(-1.0), - 0.0, - crate::design_tokens().drop_target_container_stroke(), - ), + Shape::rect_stroke(bg_rect_to_paint.shrink(stroke.width), 0.0, stroke), ); } diff --git a/crates/viewer/re_ui/src/ui_ext.rs b/crates/viewer/re_ui/src/ui_ext.rs index 547332d0f6d3..561f772b2ab0 100644 --- a/crates/viewer/re_ui/src/ui_ext.rs +++ b/crates/viewer/re_ui/src/ui_ext.rs @@ -1094,7 +1094,11 @@ pub trait UiExt { return *span; } - if node.has_visible_frame() || node.is_area_ui() || node.is_root_ui() { + if node.has_visible_frame() + || node.is_area_ui() + || node.is_panel_ui() + || node.is_root_ui() + { return (node.max_rect + node.frame().inner_margin).x_range(); } } From e6d4713bfcd6ad11fe5fdf188e3914ab076a74de Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 19:06:28 +0100 Subject: [PATCH 10/16] have the pill feedback be controllable from the drop location code. pill now looks gray when not droppable. --- .../re_blueprint_tree/src/blueprint_tree.rs | 24 ++- crates/viewer/re_viewer/src/app_state.rs | 15 +- .../re_viewer_context/src/drag_and_drop.rs | 198 ++++++++++++++---- crates/viewer/re_viewer_context/src/lib.rs | 2 +- .../re_viewer_context/src/test_context.rs | 8 +- .../re_viewer_context/src/viewer_context.rs | 18 +- crates/viewer/re_viewport/src/viewport_ui.rs | 14 +- 7 files changed, 202 insertions(+), 77 deletions(-) diff --git a/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs b/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs index d22b70d8c987..3af575f1da63 100644 --- a/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs +++ b/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs @@ -10,7 +10,7 @@ use re_types::blueprint::components::Visible; use re_ui::{drag_and_drop::DropTarget, list_item, ContextExt as _, DesignTokens, UiExt as _}; use re_viewer_context::{ contents_name_style, icon_for_container_kind, CollapseScope, Contents, DataResultNodeOrPath, - DragAndDropPayload, SystemCommandSender, + DragAndDropFeedback, DragAndDropPayload, SystemCommandSender, }; use re_viewer_context::{ ContainerId, DataQueryResult, DataResultNode, HoverHighlight, Item, ViewId, ViewerContext, @@ -101,6 +101,7 @@ impl BlueprintTree { // handle drag and drop interaction on empty space self.handle_empty_space_drag_and_drop_interaction( + ctx, viewport, ui, empty_space_response.rect, @@ -192,6 +193,7 @@ impl BlueprintTree { ctx.handle_select_hover_drag_interactions(&item_response, item, true); self.handle_root_container_drag_and_drop_interaction( + ctx, viewport, ui, Contents::Container(container_id), @@ -275,6 +277,7 @@ impl BlueprintTree { viewport.set_content_visibility(ctx, &content, visible); self.handle_drag_and_drop_interaction( + ctx, viewport, ui, content, @@ -411,6 +414,7 @@ impl BlueprintTree { viewport.set_content_visibility(ctx, &content, visible); self.handle_drag_and_drop_interaction( + ctx, viewport, ui, content, @@ -629,6 +633,7 @@ impl BlueprintTree { fn handle_root_container_drag_and_drop_interaction( &mut self, + ctx: &ViewerContext<'_>, viewport: &ViewportBlueprint, ui: &egui::Ui, contents: Contents, @@ -672,12 +677,13 @@ impl BlueprintTree { ); if let Some(drop_target) = drop_target { - self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); + self.handle_contents_drop_target(ctx, viewport, ui, dragged_contents, &drop_target); } } fn handle_drag_and_drop_interaction( &mut self, + ctx: &ViewerContext<'_>, viewport: &ViewportBlueprint, ui: &egui::Ui, contents: Contents, @@ -751,12 +757,13 @@ impl BlueprintTree { ); if let Some(drop_target) = drop_target { - self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); + self.handle_contents_drop_target(ctx, viewport, ui, dragged_contents, &drop_target); } } fn handle_empty_space_drag_and_drop_interaction( &mut self, + ctx: &ViewerContext<'_>, viewport: &ViewportBlueprint, ui: &egui::Ui, empty_space: egui::Rect, @@ -792,12 +799,13 @@ impl BlueprintTree { usize::MAX, ); - self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); + self.handle_contents_drop_target(ctx, viewport, ui, dragged_contents, &drop_target); } } fn handle_contents_drop_target( &mut self, + ctx: &ViewerContext<'_>, viewport: &ViewportBlueprint, ui: &Ui, dragged_contents: &[Contents], @@ -816,6 +824,8 @@ impl BlueprintTree { false }; if dragged_contents.iter().any(parent_contains_dragged_content) { + ctx.drag_and_drop_manager + .set_feedback(DragAndDropFeedback::Reject); return; } @@ -826,7 +836,9 @@ impl BlueprintTree { ); let Contents::Container(target_container_id) = drop_target.target_parent_id else { - // this shouldn't append + // this shouldn't happen + ctx.drag_and_drop_manager + .set_feedback(DragAndDropFeedback::Reject); return; }; @@ -839,6 +851,8 @@ impl BlueprintTree { egui::DragAndDrop::clear_payload(ui.ctx()); } else { + ctx.drag_and_drop_manager + .set_feedback(DragAndDropFeedback::Accept); self.next_candidate_drop_parent_container_id = Some(target_container_id); } } diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index 1d0a1559b13f..3870f63743b0 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -8,8 +8,8 @@ use re_smart_channel::ReceiveSet; use re_types::blueprint::components::PanelState; use re_ui::{ContextExt as _, DesignTokens}; use re_viewer_context::{ - drag_and_drop_payload_cursor_ui, AppOptions, ApplicationSelectionState, BlueprintUndoState, - CommandSender, ComponentUiRegistry, PlayState, RecordingConfig, StoreContext, StoreHub, + AppOptions, ApplicationSelectionState, BlueprintUndoState, CommandSender, ComponentUiRegistry, + DragAndDropManager, PlayState, RecordingConfig, StoreContext, StoreHub, SystemCommandSender as _, ViewClassExt as _, ViewClassRegistry, ViewStates, ViewerContext, }; use re_viewport::ViewportUi; @@ -215,8 +215,9 @@ impl AppState { ); // The root container cannot be dragged. - let undraggable_items = - re_viewer_context::Item::Container(viewport_ui.blueprint.root_container).into(); + let drag_and_drop_manager = DragAndDropManager::new(re_viewer_context::Item::Container( + viewport_ui.blueprint.root_container, + )); let applicable_entities_per_visualizer = view_class_registry.applicable_entities_for_visualizer_systems(&recording.store_id()); @@ -278,7 +279,7 @@ impl AppState { render_ctx: Some(render_ctx), command_sender, focused_item, - undraggable_items: &undraggable_items, + drag_and_drop_manager: &drag_and_drop_manager, }; // We move the time at the very start of the frame, @@ -350,7 +351,7 @@ impl AppState { render_ctx: Some(render_ctx), command_sender, focused_item, - undraggable_items: &undraggable_items, + drag_and_drop_manager: &drag_and_drop_manager, }; if *show_settings_ui { @@ -517,7 +518,7 @@ impl AppState { // add_view_or_container_modal_ui(&ctx, &viewport_ui.blueprint, ui); - drag_and_drop_payload_cursor_ui(ctx.egui_ctx); + drag_and_drop_manager.payload_cursor_ui(ctx.egui_ctx); // Process deferred layout operations and apply updates back to blueprint: viewport_ui.save_to_blueprint_store(&ctx, view_class_registry); diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index 8864e53760b5..8c3f2553c64f 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -1,9 +1,46 @@ -//! Implement a global drag-and-drop payload type that enable dragging from various parts of the UI -//! (e.g., from the streams tree to the viewport, etc.). +//! Support for viewer-wide drag-and-drop of [`crate::Item`]s. +//! +//! ## Theory of operation +//! +//! ### Setup +//! +//! A [`DragAndDropManager`] should be created at the start of the frame and made available to the +//! entire UI code. +//! +//! +//! ### Initiating a drag +//! +//! Any UI representation of an [`crate::Item`] may initiate a drag. +//! [`crate::ViewerContext::handle_select_hover_drag_interactions`] will handle that automatically +//! when passed `true` for its `draggable` argument. +//! +//! +//! ### Reacting to a drag and accepting a drop +//! +//! This part of the process is more involved and typically includes the following steps: +//! +//! 1. When hovered, the receiving UI element should check for a compatible payload using +//! [`egui::DragAndDrop::payload`] and matching one or more variants of the returned +//! [`DragAndDropPayload`], if any. +//! +//! 2. If an acceptable payload type is being dragged, the UI element should provide appropriate +//! visual feedback. This includes: +//! - Calling [`DragAndDropManager::set_feedback`] with the appropriate feedback. +//! - Drawing a frame around the target container with +//! [`re_ui::DesignToken::drop_target_container_stroke`]. +//! - Optionally provide more feedback, e.g., where exactly the payload will be inserted within +//! the container. +//! +//! 3. If the mouse is released (using [`egui::PointerState::any_released`]), the payload must be +//! actually transferred to the container and [`egui::DragAndDrop::clear_payload`] must be +//! called. use std::fmt::{Display, Formatter}; +use std::sync::Arc; use itertools::Itertools; +use parking_lot::Mutex; + use re_entity_db::InstancePath; use re_log_types::EntityPath; use re_ui::{ @@ -14,7 +51,6 @@ use re_ui::{ use crate::{Contents, Item, ItemCollection}; -//TODO(ab): add more type of things we can drag, in particular entity paths #[derive(Debug)] pub enum DragAndDropPayload { /// The dragged content is made only of [`Contents`]. @@ -80,53 +116,123 @@ impl std::fmt::Display for DragAndDropPayload { } } -/// Display the currently dragged payload as a pill in the UI. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum DragAndDropFeedback { + /// The payload type is irrelevant to me. + /// + /// For example, dropping a view and/or contain onto an existing view in the viewport is + /// irrelevant. + /// + /// This is the default displayed feedback, unless explicitly set otherwise by some UI hovered + /// UI element. + #[default] + Ignore, + + /// The payload type is acceptable and could successfully be dropped at the current location. + Accept, + + /// The payload type is correct, but it's content cannot be accepted by the current drop location. + /// + /// For example, a view might reject an entity because it already contains it. + Reject, +} + +/// Helper to handle drag-and-drop operations. /// -/// This should be called once per frame. -pub fn drag_and_drop_payload_cursor_ui(ctx: &egui::Context) { - if let Some(payload) = egui::DragAndDrop::payload::(ctx) { - if let Some(pointer_pos) = ctx.pointer_interact_pos() { - let icon = match payload.as_ref() { - DragAndDropPayload::Contents { .. } => &re_ui::icons::DND_MOVE, - DragAndDropPayload::Entities { .. } => &re_ui::icons::DND_ADD_TO_EXISTING, - // don't draw anything for invalid selection - DragAndDropPayload::Invalid => return, - }; - - let layer_id = egui::LayerId::new( - egui::Order::Tooltip, - egui::Id::new("drag_and_drop_payload_layer"), - ); - - let mut ui = egui::Ui::new( - ctx.clone(), - egui::Id::new("rerun_drag_and_drop_payload_ui"), - egui::UiBuilder::new().layer_id(layer_id), - ); - - ui.set_opacity(0.7); - - let response = drag_pill_frame() - .show(&mut ui, |ui| { - let text_color = ui.visuals().widgets.inactive.text_color(); - - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - - ui.small_icon(icon, Some(text_color)); - ui.label(egui::RichText::new(payload.to_string()).color(text_color)); - }); - }) - .response; - - let delta = pointer_pos - response.rect.right_bottom(); - ctx.transform_layer_shapes(layer_id, emath::TSTransform::from_translation(delta)); +/// This helper should be constructed at the beginning of the frame and disposed of at the end. +/// Its [`Self::payload_cursor_ui`] method should be called late during the frame (after the rest of +/// the UI has a chance to update the feedback). +pub struct DragAndDropManager { + /// Items that may not be dragged, e.g., because they are not movable nor copiable. + undraggable_items: ItemCollection, + + feedback: Arc>, +} + +impl DragAndDropManager { + /// Create a [`DragAndDropManager`] by providing a list of undraggable items. + pub fn new(undraggable_items: impl Into) -> Self { + Self { + undraggable_items: undraggable_items.into(), + feedback: Default::default(), + } + } + + /// Set the feedback to display to the user based on drop acceptability for the UI currently + /// hovered. + /// + /// By default, the feedback is unset and the pill/cursor are displayed in a "neutral" way, + /// indicating that the current drag-and-drop payload is valid but not hovered over a related + /// UI. + /// + /// If the payload type is compatible with the hovered UI element, that element should set the + /// feedback to either [`DragAndDropFeedback::Accept`] or [`DragAndDropFeedback::Reject`], based + /// on whether the actual payload content may meaningfully be dropped. + /// + /// For example, a view generally accepts a dragged entity but may occasionally reject it if + /// it already contains it. + pub fn set_feedback(&self, feedback: DragAndDropFeedback) { + *self.feedback.lock() = feedback; + } + + /// Checks if items are draggable based on the list of undraggable items. + pub fn are_items_draggable(&self, items: &ItemCollection) -> bool { + self.undraggable_items + .iter_items() + .all(|item| !items.contains_item(item)) + } + + /// Display the currently dragged payload as a pill in the UI. + /// + /// This should be called once per frame. + pub fn payload_cursor_ui(&self, ctx: &egui::Context) { + if let Some(payload) = egui::DragAndDrop::payload::(ctx) { + if let Some(pointer_pos) = ctx.pointer_interact_pos() { + let icon = match payload.as_ref() { + DragAndDropPayload::Contents { .. } => &re_ui::icons::DND_MOVE, + DragAndDropPayload::Entities { .. } => &re_ui::icons::DND_ADD_TO_EXISTING, + // don't draw anything for invalid selection + DragAndDropPayload::Invalid => return, + }; + + let layer_id = egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("drag_and_drop_payload_layer"), + ); + + let mut ui = egui::Ui::new( + ctx.clone(), + egui::Id::new("rerun_drag_and_drop_payload_ui"), + egui::UiBuilder::new().layer_id(layer_id), + ); + + ui.set_opacity(0.7); + + //TODO: we should handle all 3 states differently. + let payload_is_currently_droppable = + *self.feedback.lock() == DragAndDropFeedback::Accept; + let response = drag_pill_frame(payload_is_currently_droppable) + .show(&mut ui, |ui| { + let text_color = ui.visuals().widgets.inactive.text_color(); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + + ui.small_icon(icon, Some(text_color)); + ui.label(egui::RichText::new(payload.to_string()).color(text_color)); + }); + }) + .response; + + let delta = pointer_pos - response.rect.right_bottom(); + ctx.transform_layer_shapes(layer_id, emath::TSTransform::from_translation(delta)); + } } } } -fn drag_pill_frame() -> egui::Frame { - let hue = Hue::Blue; +fn drag_pill_frame(droppable: bool) -> egui::Frame { + let hue = if droppable { Hue::Blue } else { Hue::Gray }; egui::Frame { fill: re_ui::design_tokens().color(ColorToken::new(hue, S325)), @@ -134,7 +240,7 @@ fn drag_pill_frame() -> egui::Frame { 1.0, re_ui::design_tokens().color(ColorToken::new(hue, S375)), ), - rounding: (2.0).into(), + rounding: 2.0.into(), inner_margin: egui::Margin { left: 6.0, right: 9.0, diff --git a/crates/viewer/re_viewer_context/src/lib.rs b/crates/viewer/re_viewer_context/src/lib.rs index d276cb4b793b..c915efe53fba 100644 --- a/crates/viewer/re_viewer_context/src/lib.rs +++ b/crates/viewer/re_viewer_context/src/lib.rs @@ -53,7 +53,7 @@ pub use self::{ component_ui_registry::{ComponentUiRegistry, ComponentUiTypes, UiLayout}, contents::{blueprint_id_to_tile_id, Contents, ContentsName}, data_result_node_or_path::DataResultNodeOrPath, - drag_and_drop::{drag_and_drop_payload_cursor_ui, DragAndDropPayload}, + drag_and_drop::{DragAndDropFeedback, DragAndDropManager, DragAndDropPayload}, file_dialog::santitize_file_name, image_info::{ColormapWithRange, ImageInfo}, item::Item, diff --git a/crates/viewer/re_viewer_context/src/test_context.rs b/crates/viewer/re_viewer_context/src/test_context.rs index 70fae8e9238e..0e2cbc4b829f 100644 --- a/crates/viewer/re_viewer_context/src/test_context.rs +++ b/crates/viewer/re_viewer_context/src/test_context.rs @@ -6,8 +6,8 @@ use re_log_types::{StoreId, StoreKind}; use crate::{ blueprint_timeline, command_channel, ApplicationSelectionState, CommandReceiver, CommandSender, - ComponentUiRegistry, RecordingConfig, StoreContext, SystemCommand, ViewClassRegistry, - ViewerContext, + ComponentUiRegistry, ItemCollection, RecordingConfig, StoreContext, SystemCommand, + ViewClassRegistry, ViewerContext, }; /// Harness to execute code that rely on [`crate::ViewerContext`]. @@ -90,7 +90,7 @@ impl TestContext { hub: &Default::default(), }; - let undraggable_items = Default::default(); + let drag_and_drop_helper = crate::DragAndDropManager::new(ItemCollection::default()); let ctx = ViewerContext { app_options: &Default::default(), @@ -110,7 +110,7 @@ impl TestContext { render_ctx: None, command_sender: &self.command_sender, focused_item: &None, - undraggable_items: &undraggable_items, + drag_and_drop_manager: &drag_and_drop_helper, }; func(&ctx); diff --git a/crates/viewer/re_viewer_context/src/viewer_context.rs b/crates/viewer/re_viewer_context/src/viewer_context.rs index 1b1d470dc7c5..1728587c7aaf 100644 --- a/crates/viewer/re_viewer_context/src/viewer_context.rs +++ b/crates/viewer/re_viewer_context/src/viewer_context.rs @@ -8,8 +8,9 @@ use re_query::StorageEngineReadGuard; use crate::drag_and_drop::DragAndDropPayload; use crate::{ query_context::DataQueryResult, AppOptions, ApplicableEntities, ApplicationSelectionState, - Caches, CommandSender, ComponentUiRegistry, IndicatedEntities, ItemCollection, PerVisualizer, - StoreContext, SystemCommandSender as _, TimeControl, ViewClassRegistry, ViewId, + Caches, CommandSender, ComponentUiRegistry, DragAndDropManager, IndicatedEntities, + ItemCollection, PerVisualizer, StoreContext, SystemCommandSender as _, TimeControl, + ViewClassRegistry, ViewId, }; /// Common things needed by many parts of the viewer. @@ -81,12 +82,8 @@ pub struct ViewerContext<'a> { /// that last several frames. pub focused_item: &'a Option, - /// If a selection contains any `undraggable_items`, it may not be dragged. - /// - /// This is a rather ugly workaround to handle the case of the root container not being - /// draggable, but also being unknown to the drag-and-drop machinery in `re_viewer_context`. - //TODO(ab): figure out a way to deal with that in a cleaner way. - pub undraggable_items: &'a ItemCollection, + /// Helper object to manage drag-and-drop operations. + pub drag_and_drop_manager: &'a DragAndDropManager, } impl ViewerContext<'_> { @@ -180,9 +177,8 @@ impl ViewerContext<'_> { } let selection_may_be_dragged = self - .undraggable_items - .iter_items() - .all(|item| !selected_items.contains_item(item)); + .drag_and_drop_manager + .are_items_draggable(&selected_items); let payload = if selection_may_be_dragged { DragAndDropPayload::from_items(&selected_items) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 804f3e14a24e..3dd62c5f3573 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -9,9 +9,9 @@ use re_context_menu::{context_menu_ui_for_item, SelectionUpdateBehavior}; use re_log_types::{EntityPath, EntityPathRule}; use re_ui::{design_tokens, ContextExt as _, DesignTokens, Icon, UiExt as _}; use re_viewer_context::{ - blueprint_id_to_tile_id, icon_for_container_kind, Contents, DragAndDropPayload, Item, - PublishedViewInfo, SystemExecutionOutput, ViewClassRegistry, ViewId, ViewQuery, ViewStates, - ViewerContext, + blueprint_id_to_tile_id, icon_for_container_kind, Contents, DragAndDropFeedback, + DragAndDropPayload, Item, PublishedViewInfo, SystemExecutionOutput, ViewClassRegistry, ViewId, + ViewQuery, ViewStates, ViewerContext, }; use re_viewport_blueprint::{ create_entity_add_info, ViewBlueprint, ViewportBlueprint, ViewportCommand, @@ -254,6 +254,14 @@ impl ViewportUi { }; let any_is_visualizable = entities.iter().any(can_entity_be_added); + + ctx.drag_and_drop_manager + .set_feedback(if any_is_visualizable { + DragAndDropFeedback::Accept + } else { + DragAndDropFeedback::Reject + }); + if !any_is_visualizable { return false; } From 5638e79ec7370aa2ec3b98bca620b38d83a14f89 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 12 Dec 2024 21:29:38 +0100 Subject: [PATCH 11/16] Update cursor based on feedback from drop zone --- Cargo.lock | 22 +++++++++---------- Cargo.toml | 14 ++++++------ .../re_viewer_context/src/drag_and_drop.rs | 13 ++++++++--- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd21dfd45a73..7bec7c9064de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,7 +1934,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "ecolor" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "bytemuck", "color-hex", @@ -1951,7 +1951,7 @@ checksum = "18aade80d5e09429040243ce1143ddc08a92d7a22820ac512610410a4dd5214f" [[package]] name = "eframe" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "ahash", "bytemuck", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "egui" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "accesskit", "ahash", @@ -2007,7 +2007,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "ahash", "bytemuck", @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "accesskit_winit", "ahash", @@ -2068,7 +2068,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "ahash", "egui", @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "ahash", "bytemuck", @@ -2103,7 +2103,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "dify", "egui", @@ -2172,7 +2172,7 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "bytemuck", "serde", @@ -2288,7 +2288,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" dependencies = [ "ab_glyph", "ahash", @@ -2307,7 +2307,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" +source = "git+https://github.com/emilk/egui.git?rev=ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048#ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" [[package]] name = "equivalent" diff --git a/Cargo.toml b/Cargo.toml index 73d17d1b009f..1ac77b684f88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -559,13 +559,13 @@ significant_drop_tightening = "allow" # An update of parking_lot made this trigg # As a last resport, patch with a commit to our own repository. # ALWAYS document what PR the commit hash is part of, or when it was merged into the upstream trunk. -ecolor = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -eframe = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -egui = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -egui_extras = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -egui_kittest = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -egui-wgpu = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 -emath = { git = "https://github.com/emilk/egui.git", rev = "13352d606496d7b1c5fd6fcfbe3c85baae39c040" } # egui master 2024-12-09 +ecolor = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +eframe = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +egui = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +egui_extras = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +egui_kittest = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +egui-wgpu = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 +emath = { git = "https://github.com/emilk/egui.git", rev = "ba060a2c878bfefa5aead8e5fe0b9e69ab4c9048" } # egui master 2024-12-12 # Useful while developing: # ecolor = { path = "../../egui/crates/ecolor" } diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index 8c3f2553c64f..e0089ed643e5 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -208,9 +208,16 @@ impl DragAndDropManager { ui.set_opacity(0.7); - //TODO: we should handle all 3 states differently. - let payload_is_currently_droppable = - *self.feedback.lock() == DragAndDropFeedback::Accept; + let feedback = *self.feedback.lock(); + + match feedback { + DragAndDropFeedback::Accept | DragAndDropFeedback::Ignore => { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing) + } + DragAndDropFeedback::Reject => ctx.set_cursor_icon(egui::CursorIcon::NoDrop), + } + + let payload_is_currently_droppable = feedback == DragAndDropFeedback::Accept; let response = drag_pill_frame(payload_is_currently_droppable) .show(&mut ui, |ui| { let text_color = ui.visuals().widgets.inactive.text_color(); From 3738fc94bc2b2db1575ed2494611a2a5a31e6cfb Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Fri, 13 Dec 2024 10:36:34 +0100 Subject: [PATCH 12/16] Pill opacity 50/70% depending on feedback + fixed pilled being cropped --- .../re_viewer_context/src/drag_and_drop.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index e0089ed643e5..314615c23c4a 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -206,15 +206,22 @@ impl DragAndDropManager { egui::UiBuilder::new().layer_id(layer_id), ); - ui.set_opacity(0.7); - let feedback = *self.feedback.lock(); match feedback { - DragAndDropFeedback::Accept | DragAndDropFeedback::Ignore => { - ctx.set_cursor_icon(egui::CursorIcon::Grabbing) + DragAndDropFeedback::Accept => { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ui.set_opacity(0.8); + } + + DragAndDropFeedback::Ignore => { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ui.set_opacity(0.5); + } + DragAndDropFeedback::Reject => { + ctx.set_cursor_icon(egui::CursorIcon::NoDrop); + ui.set_opacity(0.5); } - DragAndDropFeedback::Reject => ctx.set_cursor_icon(egui::CursorIcon::NoDrop), } let payload_is_currently_droppable = feedback == DragAndDropFeedback::Accept; @@ -254,6 +261,8 @@ fn drag_pill_frame(droppable: bool) -> egui::Frame { top: 5.0, bottom: 4.0, }, + //TODO(ab): needed to avoid the pill being cropped, not sure why? + outer_margin: egui::Margin::same(1.0), ..Default::default() } } From 12b1a36de7c5eb68f236813d6bb4d8b09d7a585e Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Fri, 13 Dec 2024 11:10:08 +0100 Subject: [PATCH 13/16] Don't force select on drag --- .../re_viewer_context/src/viewer_context.rs | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/viewer/re_viewer_context/src/viewer_context.rs b/crates/viewer/re_viewer_context/src/viewer_context.rs index 1728587c7aaf..1ab2a88c1db7 100644 --- a/crates/viewer/re_viewer_context/src/viewer_context.rs +++ b/crates/viewer/re_viewer_context/src/viewer_context.rs @@ -144,59 +144,72 @@ impl ViewerContext<'_> { /// are some guidelines: /// - Is there a meaningful destination for the dragged payload? For example, dragging stuff out /// of a modal dialog is by definition meaningless. - /// - Even if a drag destination exists, would that be obvious for the user? + /// - Even if a drag destination exists, would that be obvious to the user? /// - Is it expected for that kind of UI element to be draggable? For example, buttons aren't /// typically draggable. + /// + /// Drag vs. selection semantics: + /// + /// - When dragging an unselected item, that item only is dragged, and the selection is + /// unchanged… + /// - …unless cmd/ctrl is held, in which case the item is added to the selection and the entire + /// selection is dragged. + /// - When dragging a selected item, the entire selection is dragged as well. pub fn handle_select_hover_drag_interactions( &self, response: &egui::Response, - selection: impl Into, + interacted_items: impl Into, draggable: bool, ) { re_tracing::profile_function!(); - let selection = selection.into().into_mono_instance_path_items(self); + let interacted_items = interacted_items.into().into_mono_instance_path_items(self); let selection_state = self.selection_state(); if response.hovered() { - selection_state.set_hovered(selection.clone()); + selection_state.set_hovered(interacted_items.clone()); } if draggable && response.drag_started() { let mut selected_items = selection_state.selected_items().clone(); - let is_already_selected = selection + let is_already_selected = interacted_items .iter() .all(|(item, _)| selected_items.contains_item(item)); - if !is_already_selected { - if response.ctx.input(|i| i.modifiers.command) { - selected_items.extend(selection); - } else { - selected_items = selection; - } + + let is_cmd_held = response.ctx.input(|i| i.modifiers.command); + + // see semantics description in the docstring + let dragged_items = if !is_already_selected && is_cmd_held { + selected_items.extend(interacted_items); selection_state.set_selection(selected_items.clone()); - } + selected_items + } else if !is_already_selected { + interacted_items + } else { + selected_items + }; - let selection_may_be_dragged = self + let items_may_be_dragged = self .drag_and_drop_manager - .are_items_draggable(&selected_items); + .are_items_draggable(&dragged_items); - let payload = if selection_may_be_dragged { - DragAndDropPayload::from_items(&selected_items) + let payload = if items_may_be_dragged { + DragAndDropPayload::from_items(&dragged_items) } else { DragAndDropPayload::Invalid }; egui::DragAndDrop::set_payload(&response.ctx, payload); } else if response.double_clicked() { - if let Some(item) = selection.first_item() { + if let Some(item) = interacted_items.first_item() { self.command_sender .send_system(crate::SystemCommand::SetFocus(item.clone())); } } else if response.clicked() { if response.ctx.input(|i| i.modifiers.command) { - selection_state.toggle_selection(selection); + selection_state.toggle_selection(interacted_items); } else { - selection_state.set_selection(selection); + selection_state.set_selection(interacted_items); } } } From 8559af70e8cf58a9775dc96c18c6e1dad7be1bb9 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Fri, 13 Dec 2024 11:34:29 +0100 Subject: [PATCH 14/16] doclink --- crates/viewer/re_viewer_context/src/drag_and_drop.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index 314615c23c4a..1d24bf2e2f03 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -27,7 +27,7 @@ //! visual feedback. This includes: //! - Calling [`DragAndDropManager::set_feedback`] with the appropriate feedback. //! - Drawing a frame around the target container with -//! [`re_ui::DesignToken::drop_target_container_stroke`]. +//! [`re_ui::DesignTokens::drop_target_container_stroke`]. //! - Optionally provide more feedback, e.g., where exactly the payload will be inserted within //! the container. //! From f5a7f3db797dcfe0cc1343896cad5a8c0add5d92 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Mon, 16 Dec 2024 14:20:39 +0100 Subject: [PATCH 15/16] Add two release check list test --- .../check_drag_and_drop_selection.py | 78 +++++++++++++++ .../check_entity_drag_and_drop.py | 94 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/python/release_checklist/check_drag_and_drop_selection.py create mode 100644 tests/python/release_checklist/check_entity_drag_and_drop.py diff --git a/tests/python/release_checklist/check_drag_and_drop_selection.py b/tests/python/release_checklist/check_drag_and_drop_selection.py new file mode 100644 index 000000000000..2ca4d7ee9b82 --- /dev/null +++ b/tests/python/release_checklist/check_drag_and_drop_selection.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +from argparse import Namespace +from uuid import uuid4 + +import numpy as np +import rerun as rr +import rerun.blueprint as rrb + +README = """\ +# Drag-and-drop selection + +The goal of this test is to test the selection behavior of drag-and-drop. + +#### View selects on a successful drop + +1. Select the `cos_curve` entity in the streams tree. +2. Drag it to the PLOT view and drop it. +3. _Expect_: the entity is added to the view, and the view becomes selected. + + +#### View doesn't select on a failed drop + +1. Select the `cos_curve` entity again. +2. Drag it to the PLOT view (it should be rejected) and drop it. +3. _Expect_: nothing happens, and the selection is not changed. + + +#### Dragging an unselected item doesn't change the selection + +1. Select the PLOT view. +2. Drag drag the `line_curve` entity to the PLOT view and drop it. +2. _Expect_: + - The selection remains unchanged (the PLOT view is still selected). + - The `line_curve` entity is added to the view. + +""" + + +def log_readme() -> None: + rr.log("readme", rr.TextDocument(README, media_type=rr.MediaType.MARKDOWN), static=True) + + +def blueprint() -> rrb.BlueprintLike: + return rrb.Horizontal( + rrb.TimeSeriesView(origin="/", contents=[], name="PLOT"), + rrb.TextDocumentView(origin="readme"), + ) + + +def log_some_scalar_entities() -> None: + times = np.arange(100) + curves = [ + ("cos_curve", np.cos(times / 100 * 2 * np.pi)), + ("line_curve", times / 100 + 0.2), + ] + + time_column = rr.TimeSequenceColumn("frame", times) + + for path, curve in curves: + rr.send_columns(path, times=[time_column], components=[rr.components.ScalarBatch(curve)]) + + +def run(args: Namespace) -> None: + rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4(), default_blueprint=blueprint()) + + log_readme() + log_some_scalar_entities() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Interactive release checklist") + rr.script_add_args(parser) + args = parser.parse_args() + run(args) diff --git a/tests/python/release_checklist/check_entity_drag_and_drop.py b/tests/python/release_checklist/check_entity_drag_and_drop.py new file mode 100644 index 000000000000..0e9d718064dd --- /dev/null +++ b/tests/python/release_checklist/check_entity_drag_and_drop.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +from argparse import Namespace +from uuid import uuid4 + +import numpy as np +import rerun as rr +import rerun.blueprint as rrb + +README = """\ +# Entity drag-and-drop + +The goal of this test is to check the behavior of views when entities are dragged-and-dropped over them. +The drag payload pill _and_ the mouse cursor should change based on the underlying UI element feedback. +This table summarizes the three possible states: + +**Note**: actual cursor shape may vary depending on the platform (OS, browser, etc). + +| **Ignore** | **Accept** | **Reject** | +| --- | --- | --- | +| Gray pill | Blue pill | Gray pill | +| Hand cursor | Hand cursor | No-drop cursor | +| ![ignore](https://static.rerun.io/dnd-cursor-ignore/ec48f64a119bddd2c9cbd55410021ef0e1a30feb/full.png) | ![accept](https://static.rerun.io/dnd-cursor-accept/7b40cd79fd99ba2c31617d2f40f56c5c8ba3aca0/full.png) | ![reject](https://static.rerun.io/dnd-cursor-reject/6f105e9689be33b2e0fff5bb1ad42cbb6271b622/full.png) | + + +#### Ignore state + +1. Drag any entity from the streams tree over the blueprint tree (which ignores entities). +2. _Expect_: pill/cursor in ignore state, nothing happens on drop + + +#### Accept state + +1. Drag the `cos_curve` entity from the streams tree to the BOTTOM view and drop it. +2. _Expect_: pill/cursor in accept state, entity is added to the BOTTOM view on drop. + + +#### Reject state + +1. Drag THE SAME `cos_curve` entity from the streams tree. +2. _Expect_: + - BOTTOM rejects the entity, nothing happens on drop. + - TOP accepts the entity, entity is added to the TOP view on drop. + +#### Multi-selection drag + +1. Multi-select `cos_curve` and `line_curve` entities from the streams tree. +2. _Expect_: both views accept the entities, only `line_curve` is added on drop. + +""" + + +def log_readme() -> None: + rr.log("readme", rr.TextDocument(README, media_type=rr.MediaType.MARKDOWN), static=True) + + +def blueprint() -> rrb.BlueprintLike: + return rrb.Horizontal( + rrb.Vertical( + rrb.TimeSeriesView(origin="/", contents=[], name="TOP"), + rrb.TimeSeriesView(origin="/", contents=[], name="BOTTOM"), + ), + rrb.TextDocumentView(origin="readme"), + ) + + +def log_some_scalar_entities() -> None: + times = np.arange(100) + curves = [ + ("cos_curve", np.cos(times / 100 * 2 * np.pi)), + ("line_curve", times / 100 + 0.2), + ] + + time_column = rr.TimeSequenceColumn("frame", times) + + for path, curve in curves: + rr.send_columns(path, times=[time_column], components=[rr.components.ScalarBatch(curve)]) + + +def run(args: Namespace) -> None: + rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4(), default_blueprint=blueprint()) + + log_readme() + log_some_scalar_entities() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Interactive release checklist") + rr.script_add_args(parser) + args = parser.parse_args() + run(args) From a2716893750c6e34b59492844e3d04446bbadf9f Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Tue, 17 Dec 2024 10:53:20 +0100 Subject: [PATCH 16/16] Review comment --- Cargo.lock | 1 + crates/viewer/re_viewer_context/Cargo.toml | 1 + crates/viewer/re_viewer_context/src/drag_and_drop.rs | 12 ++++++------ crates/viewer/re_viewer_context/src/test_context.rs | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dba7ac4c891..8d679e2c1802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6851,6 +6851,7 @@ dependencies = [ "bit-vec", "bitflags 2.6.0", "bytemuck", + "crossbeam", "directories", "egui", "egui-wgpu", diff --git a/crates/viewer/re_viewer_context/Cargo.toml b/crates/viewer/re_viewer_context/Cargo.toml index 71bd3774d201..9fc29fa4b27f 100644 --- a/crates/viewer/re_viewer_context/Cargo.toml +++ b/crates/viewer/re_viewer_context/Cargo.toml @@ -42,6 +42,7 @@ anyhow.workspace = true bit-vec.workspace = true bitflags.workspace = true bytemuck.workspace = true +crossbeam.workspace = true directories.workspace = true egui_extras.workspace = true egui_tiles.workspace = true diff --git a/crates/viewer/re_viewer_context/src/drag_and_drop.rs b/crates/viewer/re_viewer_context/src/drag_and_drop.rs index 1d24bf2e2f03..b7755fef0989 100644 --- a/crates/viewer/re_viewer_context/src/drag_and_drop.rs +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -36,10 +36,8 @@ //! called. use std::fmt::{Display, Formatter}; -use std::sync::Arc; use itertools::Itertools; -use parking_lot::Mutex; use re_entity_db::InstancePath; use re_log_types::EntityPath; @@ -82,6 +80,8 @@ fn try_item_collection_to_contents(items: &ItemCollection) -> Option Option> { items .iter() + // Note: this is not a filter map, because we rely on the implicit "all" semantics of + // `collect`: we return `Some>` only if all iterated items are `Some<_>`. .map(|(item, _)| match item { Item::InstancePath(instance_path) | Item::DataResult(_, instance_path) => instance_path .is_all() @@ -139,14 +139,14 @@ pub enum DragAndDropFeedback { /// Helper to handle drag-and-drop operations. /// -/// This helper should be constructed at the beginning of the frame and disposed of at the end. +/// This helper must be constructed at the beginning of the frame and disposed of at the end. /// Its [`Self::payload_cursor_ui`] method should be called late during the frame (after the rest of /// the UI has a chance to update the feedback). pub struct DragAndDropManager { /// Items that may not be dragged, e.g., because they are not movable nor copiable. undraggable_items: ItemCollection, - feedback: Arc>, + feedback: crossbeam::atomic::AtomicCell, } impl DragAndDropManager { @@ -172,7 +172,7 @@ impl DragAndDropManager { /// For example, a view generally accepts a dragged entity but may occasionally reject it if /// it already contains it. pub fn set_feedback(&self, feedback: DragAndDropFeedback) { - *self.feedback.lock() = feedback; + self.feedback.store(feedback); } /// Checks if items are draggable based on the list of undraggable items. @@ -206,7 +206,7 @@ impl DragAndDropManager { egui::UiBuilder::new().layer_id(layer_id), ); - let feedback = *self.feedback.lock(); + let feedback = self.feedback.load(); match feedback { DragAndDropFeedback::Accept => { diff --git a/crates/viewer/re_viewer_context/src/test_context.rs b/crates/viewer/re_viewer_context/src/test_context.rs index 0e2cbc4b829f..e661e60cf4fc 100644 --- a/crates/viewer/re_viewer_context/src/test_context.rs +++ b/crates/viewer/re_viewer_context/src/test_context.rs @@ -90,7 +90,7 @@ impl TestContext { hub: &Default::default(), }; - let drag_and_drop_helper = crate::DragAndDropManager::new(ItemCollection::default()); + let drag_and_drop_manager = crate::DragAndDropManager::new(ItemCollection::default()); let ctx = ViewerContext { app_options: &Default::default(), @@ -110,7 +110,7 @@ impl TestContext { render_ctx: None, command_sender: &self.command_sender, focused_item: &None, - drag_and_drop_manager: &drag_and_drop_helper, + drag_and_drop_manager: &drag_and_drop_manager, }; func(&ctx);