From 02ae9c563bd86c4ba95295a09dce02ccd3da3ef8 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler <49431240+abey79@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:46:13 +0100 Subject: [PATCH] Entities can be dragged from the blueprint tree and streams tree to an existing view in the viewport (#8431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Related * Closes #8266 * Related to #8267 ### What This PR makes it possible to drag one or more entities from the blueprint and streams tree to existing views in the viewport. This involves introducing and/or adjusting a whole bunch of semantics around selection and dragging. #### `DragAndDropFeedback` This PR introduced the notion of drag-and-drop feedback from the hovered ui element. Feedback may be either of: - **Ignore** (default): hovered element is uninterested in the type of drag payload, or in any payload at all, or is outright oblivious to all that drag-and-drop stuff. - **Reject**: hovered element is compatible with the _type_ of drag payload, but not its actual _content_. For example, a view might already contains the dragged entities. - **Accept**: hovered element is compatible with both the type and the content of the payload. A drop should only ever happen in the latter case. #### Payload visualisation The payload pill display is now adjusted based on the feedback, both its opacity (ranging from 50 to 80%) and its colour (grey or blue). The mouse cursor is also adjusted based on the feedback. #### Drop container visualisation This PR slightly adjust the look of the drop container visualisation: the blue frame is now 2px wide. Note that the drop container is _not_ necessarily the thing that's hovered by the mouse, see e.g. containers in the blueprint. #### Selection handling during drag-and-drop This PR slightly alter the current behaviour. Now: - dragging a selected item drags the entire selection - dragging an unselected item with `cmd` held adds that item to the selection, and drags the entire selection - dragging an unselected item drags that item, _without changing the selection_ (new) #### Entity-to-viewport-view drag semantics This is the original goal of this PR. - An existing view will accept a payload containing entities **IFF** any of these entities—or their children—is both visualisable and not already contained in that view. - An existing view will reject a payload containing entities **IFF** all of these entities are either non-visualisable or already contained. - An existing view will ignore a payload containing anything else. - When a drop succeeds: - The view will add an inclusive ("…/**") rule for each of the dropped entities that are both visualisable and not already included. - The view becomes selected. Emphasis on that last point. This subtle UX behaviour (courtesy of @gavrelina) makes the drop success and impact on the entity path filter more explicit. #### Theory of operation for drag-and-drop With this PR, a "framework" slowly starts to emerge. For now, it's mainly this bit of documentation: ```rust //! ## 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. ``` ### TODO - [x] release checklist to check the above semantics https://github.com/user-attachments/assets/047c0d41-fead-424a-b673-b6cb1479d1fa --- Cargo.lock | 1 + .../re_blueprint_tree/src/blueprint_tree.rs | 24 +- .../src/view_entity_picker.rs | 126 +----- 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 | 7 +- crates/viewer/re_ui/src/ui_ext.rs | 6 +- crates/viewer/re_viewer/src/app_state.rs | 15 +- crates/viewer/re_viewer_context/Cargo.toml | 1 + .../viewer/re_viewer_context/src/contents.rs | 10 +- .../re_viewer_context/src/drag_and_drop.rs | 370 +++++++++++++----- crates/viewer/re_viewer_context/src/lib.rs | 2 +- .../re_viewer_context/src/test_context.rs | 8 +- .../re_viewer_context/src/viewer_context.rs | 67 ++-- crates/viewer/re_viewport/src/viewport_ui.rs | 141 ++++++- .../src/entity_add_info.rs | 121 ++++++ .../viewer/re_viewport_blueprint/src/lib.rs | 2 + .../check_drag_and_drop_selection.py | 78 ++++ .../check_entity_drag_and_drop.py | 94 +++++ 19 files changed, 788 insertions(+), 299 deletions(-) create mode 100644 crates/viewer/re_viewport_blueprint/src/entity_add_info.rs 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/Cargo.lock b/Cargo.lock index 2a5d6c58e6fe..5500cc0e61f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6862,6 +6862,7 @@ dependencies = [ "bit-vec", "bitflags 2.6.0", "bytemuck", + "crossbeam", "directories", "egui", "egui-wgpu", 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_selection_panel/src/view_entity_picker.rs b/crates/viewer/re_selection_panel/src/view_entity_picker.rs index a1ad17dd02e7..513111b3bb9c 100644 --- a/crates/viewer/re_selection_panel/src/view_entity_picker.rs +++ b/crates/viewer/re_selection_panel/src/view_entity_picker.rs @@ -6,8 +6,10 @@ use re_data_ui::item_ui; use re_entity_db::{EntityPath, EntityTree, InstancePath}; use re_log_types::{EntityPathFilter, EntityPathRule}; use re_ui::{list_item, 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. /// @@ -73,10 +75,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); list_item::list_item_scope(ui, "view_entity_picker", |ui| { add_entities_tree_ui( @@ -85,7 +86,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, ); @@ -263,116 +264,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_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..125e64989d93 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(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 cdee8a6b3f06..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, - egui::Stroke::new(1.0, ui.visuals().selection.bg_fill), - ), + 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(); } } 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/Cargo.toml b/crates/viewer/re_viewer_context/Cargo.toml index 0734eb629afd..a0768622da06 100644 --- a/crates/viewer/re_viewer_context/Cargo.toml +++ b/crates/viewer/re_viewer_context/Cargo.toml @@ -43,6 +43,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/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..b7755fef0989 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,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::DesignTokens::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::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}, @@ -13,93 +49,204 @@ 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`]. 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() + // 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() + .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) } } -/// 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, - // 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(matches!( - payload.as_ref(), - &DragAndDropPayload::Invalid { .. } - )) - .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; +/// 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: crossbeam::atomic::AtomicCell, +} - let delta = pointer_pos - response.rect.right_bottom(); - ctx.transform_layer_shapes(layer_id, emath::TSTransform::from_translation(delta)); +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.store(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), + ); + + let feedback = self.feedback.load(); + + match feedback { + 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); + } + } + + 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(); + + 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(error_state: bool) -> egui::Frame { - let hue = if error_state { Hue::Red } else { 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)), @@ -107,72 +254,83 @@ fn drag_pill_frame(error_state: bool) -> 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, 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() } } -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 { +/// 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, +} + +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_viewer_context/src/lib.rs b/crates/viewer/re_viewer_context/src/lib.rs index a33bdf0ad2f0..3a78b3ec4d95 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..e661e60cf4fc 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_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, - undraggable_items: &undraggable_items, + drag_and_drop_manager: &drag_and_drop_manager, }; 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 e59ac19ed921..c4210930819d 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<'_> { @@ -147,45 +144,57 @@ 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 - .undraggable_items - .iter_items() - .all(|item| !selected_items.contains_item(item)); + let items_may_be_dragged = self + .drag_and_drop_manager + .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 }; @@ -193,16 +202,16 @@ impl ViewerContext<'_> { egui::DragAndDrop::set_payload(&response.ctx, payload); } else if response.clicked() { 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())); } } 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); } } } diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 8188e6aebd73..ad4d3a005480 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -6,12 +6,16 @@ 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, DragAndDropFeedback, + DragAndDropPayload, Item, PublishedViewInfo, SystemExecutionOutput, ViewClassRegistry, ViewId, + ViewQuery, ViewStates, ViewerContext, +}; +use re_viewport_blueprint::{ + create_entity_add_info, ViewBlueprint, ViewportBlueprint, ViewportCommand, }; -use re_viewport_blueprint::{ViewportBlueprint, ViewportCommand}; use crate::system_execution::{execute_systems_for_all_views, execute_systems_for_view}; @@ -68,8 +72,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() }; @@ -100,6 +108,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 +134,30 @@ impl ViewportUi { hovered = false; } - let stroke = if hovered { + // 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; + }; + + let Some(dragged_payload) = dragged_payload else { + break 'scope false; + }; + + 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() } else if selected { ui.ctx().selection_stroke() @@ -127,13 +167,22 @@ impl ViewportUi { // 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) - .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), + ); + } } } @@ -144,7 +193,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 +214,10 @@ impl ViewportUi { }); } - self.blueprint.deferred_commands.lock().push(ViewportCommand::SetTree(tree)); + self.blueprint + .deferred_commands + .lock() + .push(ViewportCommand::SetTree(tree)); } } }); @@ -172,6 +225,70 @@ impl ViewportUi { self.blueprint.set_maximized(maximized, ctx); } + /// 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_blueprint: &ViewBlueprint, + entities: &[EntityPath], + ) -> 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); + + ctx.drag_and_drop_manager + .set_feedback(if any_is_visualizable { + DragAndDropFeedback::Accept + } else { + DragAndDropFeedback::Reject + }); + + if !any_is_visualizable { + return false; + } + + // drop incoming! + if ctx.egui_ctx.input(|i| i.pointer.any_released()) { + egui::DragAndDrop::clear_payload(ctx.egui_ctx); + + for entity in entities { + if can_entity_be_added(entity) { + view_blueprint.contents.raw_add_entity_inclusion( + ctx, + EntityPathRule::including_subtree(entity.clone()), + ); + } + } + + ctx.selection_state() + .set_selection(Item::View(view_blueprint.id)); + + // drop is completed, no need for highlighting anymore + false + } else { + any_is_visualizable + } + } + pub fn on_frame_start(&self, ctx: &ViewerContext<'_>) { re_tracing::profile_function!(); 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 5ed6164daf1c..3068e8c7fb10 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; 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)