diff --git a/Cargo.lock b/Cargo.lock index 297e7636b886..4a5eee7bfb59 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" dependencies = [ "ahash", "bytemuck", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "egui" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" dependencies = [ "bytemuck", "serde", @@ -2288,7 +2288,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.29.1" -source = "git+https://github.com/emilk/egui.git?rev=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" 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=577ee8d22810752540636febac5660a5119c6550#577ee8d22810752540636febac5660a5119c6550" +source = "git+https://github.com/emilk/egui.git?rev=13352d606496d7b1c5fd6fcfbe3c85baae39c040#13352d606496d7b1c5fd6fcfbe3c85baae39c040" [[package]] name = "equivalent" diff --git a/Cargo.toml b/Cargo.toml index 7e6a16ea007a..7eacba039f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -560,13 +560,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 = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -eframe = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -egui = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -egui_extras = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -egui_kittest = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -egui-wgpu = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 -emath = { git = "https://github.com/emilk/egui.git", rev = "577ee8d22810752540636febac5660a5119c6550" } # egui master 2024-12-04 +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 # Useful while developing: # ecolor = { path = "../../egui/crates/ecolor" } diff --git a/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs b/crates/viewer/re_blueprint_tree/src/blueprint_tree.rs index 434ab4795640..d22b70d8c987 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, - SystemCommandSender, + DragAndDropPayload, SystemCommandSender, }; use re_viewer_context::{ ContainerId, DataQueryResult, DataResultNode, HoverHighlight, Item, ViewId, ViewerContext, @@ -168,7 +168,7 @@ impl BlueprintTree { let item_response = ui .list_item() .selected(ctx.selection().contains_item(&item)) - .draggable(false) + .draggable(true) // allowed for consistency but results in an invalid drag .drop_target_style(self.is_candidate_drop_parent_container(&container_id)) .show_flat( ui, @@ -189,7 +189,7 @@ impl BlueprintTree { SelectionUpdateBehavior::UseSelection, ); self.scroll_to_me_if_needed(ui, &item, &item_response); - ctx.select_hovered_on_click(&item_response, item); + ctx.handle_select_hover_drag_interactions(&item_response, item, true); self.handle_root_container_drag_and_drop_interaction( viewport, @@ -270,12 +270,11 @@ impl BlueprintTree { SelectionUpdateBehavior::UseSelection, ); self.scroll_to_me_if_needed(ui, &item, &response); - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, true); viewport.set_content_visibility(ctx, &content, visible); self.handle_drag_and_drop_interaction( - ctx, viewport, ui, content, @@ -406,13 +405,12 @@ impl BlueprintTree { SelectionUpdateBehavior::UseSelection, ); self.scroll_to_me_if_needed(ui, &item, &response); - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, true); let content = Contents::View(*view_id); viewport.set_content_visibility(ctx, &content, visible); self.handle_drag_and_drop_interaction( - ctx, viewport, ui, content, @@ -494,6 +492,7 @@ impl BlueprintTree { let list_item = ui .list_item() + .draggable(true) .selected(is_selected) .force_hovered(is_item_hovered); @@ -596,7 +595,7 @@ impl BlueprintTree { SelectionUpdateBehavior::UseSelection, ); self.scroll_to_me_if_needed(ui, &item, &response); - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, true); } /// Add a button to trigger the addition of a new view or container. @@ -636,16 +635,21 @@ impl BlueprintTree { response: &egui::Response, ) { // - // check if a drag is in progress and set the cursor accordingly + // check if a drag with acceptable content is in progress // - let Some(dragged_item_id) = egui::DragAndDrop::payload(ui.ctx()).map(|payload| *payload) + let Some(dragged_payload) = egui::DragAndDrop::payload::(ui.ctx()) else { - // nothing is being dragged, so nothing to do return; }; - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let DragAndDropPayload::Contents { + contents: dragged_contents, + } = dragged_payload.as_ref() + else { + // nothing we care about is being dragged + return; + }; // // find the drop target @@ -668,13 +672,12 @@ impl BlueprintTree { ); if let Some(drop_target) = drop_target { - self.handle_drop_target(viewport, ui, dragged_item_id, &drop_target); + self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); } } fn handle_drag_and_drop_interaction( &mut self, - ctx: &ViewerContext<'_>, viewport: &ViewportBlueprint, ui: &egui::Ui, contents: Contents, @@ -682,25 +685,21 @@ impl BlueprintTree { body_response: Option<&egui::Response>, ) { // - // initiate drag and force single-selection - // - - if response.drag_started() { - ctx.selection_state().set_selection(contents.as_item()); - egui::DragAndDrop::set_payload(ui.ctx(), contents); - } - - // - // check if a drag is in progress and set the cursor accordingly + // check if a drag with acceptable content is in progress // - let Some(dragged_item_id) = egui::DragAndDrop::payload(ui.ctx()).map(|payload| *payload) + let Some(dragged_payload) = egui::DragAndDrop::payload::(ui.ctx()) else { - // nothing is being dragged, so nothing to do return; }; - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let DragAndDropPayload::Contents { + contents: dragged_contents, + } = dragged_payload.as_ref() + else { + // nothing we care about is being dragged + return; + }; // // find our parent, our position within parent, and the previous container (if any) @@ -752,7 +751,7 @@ impl BlueprintTree { ); if let Some(drop_target) = drop_target { - self.handle_drop_target(viewport, ui, dragged_item_id, &drop_target); + self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); } } @@ -763,16 +762,21 @@ impl BlueprintTree { empty_space: egui::Rect, ) { // - // check if a drag is in progress and set the cursor accordingly + // check if a drag with acceptable content is in progress // - let Some(dragged_item_id) = egui::DragAndDrop::payload(ui.ctx()).map(|payload| *payload) + let Some(dragged_payload) = egui::DragAndDrop::payload::(ui.ctx()) else { - // nothing is being dragged, so nothing to do return; }; - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let DragAndDropPayload::Contents { + contents: dragged_contents, + } = dragged_payload.as_ref() + else { + // nothing we care about is being dragged + return; + }; // // prepare a drop target corresponding to "insert last in root container" @@ -788,25 +792,31 @@ impl BlueprintTree { usize::MAX, ); - self.handle_drop_target(viewport, ui, dragged_item_id, &drop_target); + self.handle_contents_drop_target(viewport, ui, dragged_contents, &drop_target); } } - fn handle_drop_target( + fn handle_contents_drop_target( &mut self, viewport: &ViewportBlueprint, ui: &Ui, - dragged_item_id: Contents, + dragged_contents: &[Contents], drop_target: &DropTarget, ) { - // We cannot allow the target location to be "inside" the dragged item, because that would amount moving - // myself inside of me. - if let Contents::Container(dragged_container_id) = &dragged_item_id { - if viewport - .is_contents_in_container(&drop_target.target_parent_id, dragged_container_id) - { - return; + // We cannot allow the target location to be "inside" any of the dragged items, because that + // would amount to moving myself inside of me. + let parent_contains_dragged_content = |content: &Contents| { + if let Contents::Container(dragged_container_id) = content { + if viewport + .is_contents_in_container(&drop_target.target_parent_id, dragged_container_id) + { + return true; + } } + false + }; + if dragged_contents.iter().any(parent_contains_dragged_content) { + return; } ui.painter().hline( @@ -822,7 +832,7 @@ impl BlueprintTree { if ui.input(|i| i.pointer.any_released()) { viewport.move_contents( - dragged_item_id, + dragged_contents.to_vec(), target_container_id, drop_target.target_position_index, ); diff --git a/crates/viewer/re_data_ui/src/instance_path.rs b/crates/viewer/re_data_ui/src/instance_path.rs index ace0423c66ae..c0b271a9b91c 100644 --- a/crates/viewer/re_data_ui/src/instance_path.rs +++ b/crates/viewer/re_data_ui/src/instance_path.rs @@ -243,7 +243,7 @@ fn component_list_ui( }); if interactive { - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, false); } } }); diff --git a/crates/viewer/re_data_ui/src/item_ui.rs b/crates/viewer/re_data_ui/src/item_ui.rs index ca1ead6183e7..6526276ab9ec 100644 --- a/crates/viewer/re_data_ui/src/item_ui.rs +++ b/crates/viewer/re_data_ui/src/item_ui.rs @@ -596,7 +596,7 @@ pub fn cursor_interact_with_selectable( let is_item_hovered = ctx.selection_state().highlight_for_ui_element(&item) == HoverHighlight::Hovered; - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, false); // TODO(andreas): How to deal with shift click for selecting ranges? if is_item_hovered { diff --git a/crates/viewer/re_selection_panel/src/selection_panel.rs b/crates/viewer/re_selection_panel/src/selection_panel.rs index 79f5d17b2e19..9d2cf9e3209c 100644 --- a/crates/viewer/re_selection_panel/src/selection_panel.rs +++ b/crates/viewer/re_selection_panel/src/selection_panel.rs @@ -675,7 +675,7 @@ fn list_existing_data_blueprints( // We don't use item_ui::cursor_interact_with_selectable here because the forced // hover background is distracting and not useful. - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, false); } } } @@ -926,7 +926,7 @@ fn show_list_item_for_container_child( &response, SelectionUpdateBehavior::Ignore, ); - ctx.select_hovered_on_click(&response, item); + ctx.handle_select_hover_drag_interactions(&response, item, false); if remove_contents { viewport.mark_user_interaction(ctx); diff --git a/crates/viewer/re_time_panel/src/lib.rs b/crates/viewer/re_time_panel/src/lib.rs index f10c0e9e420c..39e67101dc4d 100644 --- a/crates/viewer/re_time_panel/src/lib.rs +++ b/crates/viewer/re_time_panel/src/lib.rs @@ -726,7 +726,7 @@ impl TimePanel { &response, SelectionUpdateBehavior::UseSelection, ); - ctx.select_hovered_on_click(&response, item.to_item()); + ctx.handle_select_hover_drag_interactions(&response, item.to_item(), false); let is_closed = body_response.is_none(); let response_rect = response.rect; @@ -854,7 +854,7 @@ impl TimePanel { &response, SelectionUpdateBehavior::UseSelection, ); - ctx.select_hovered_on_click(&response, item.to_item()); + ctx.handle_select_hover_drag_interactions(&response, item.to_item(), false); let response_rect = response.rect; diff --git a/crates/viewer/re_ui/data/icons/dnd_add_new.png b/crates/viewer/re_ui/data/icons/dnd_add_new.png new file mode 100644 index 000000000000..ae2caea22108 Binary files /dev/null and b/crates/viewer/re_ui/data/icons/dnd_add_new.png differ diff --git a/crates/viewer/re_ui/data/icons/dnd_add_to_existing.png b/crates/viewer/re_ui/data/icons/dnd_add_to_existing.png new file mode 100644 index 000000000000..815fd872b22a Binary files /dev/null and b/crates/viewer/re_ui/data/icons/dnd_add_to_existing.png differ diff --git a/crates/viewer/re_ui/data/icons/dnd_move.png b/crates/viewer/re_ui/data/icons/dnd_move.png new file mode 100644 index 000000000000..3eb30f42e8ce Binary files /dev/null and b/crates/viewer/re_ui/data/icons/dnd_move.png differ diff --git a/crates/viewer/re_ui/src/design_tokens.rs b/crates/viewer/re_ui/src/design_tokens.rs index de05e49c0e87..50acbbd2e7e4 100644 --- a/crates/viewer/re_ui/src/design_tokens.rs +++ b/crates/viewer/re_ui/src/design_tokens.rs @@ -247,7 +247,7 @@ impl DesignTokens { egui_style.visuals.image_loading_spinners = false; - //TODO(ab): use ColorToken! + //TODO(#8333): use ColorToken! egui_style.visuals.error_fg_color = egui::Color32::from_rgb(0xAB, 0x01, 0x16); egui_style.visuals.warn_fg_color = egui::Color32::from_rgb(0xFF, 0x7A, 0x0C); diff --git a/crates/viewer/re_ui/src/icons.rs b/crates/viewer/re_ui/src/icons.rs index f63c83ba7108..6d6aed335a86 100644 --- a/crates/viewer/re_ui/src/icons.rs +++ b/crates/viewer/re_ui/src/icons.rs @@ -128,5 +128,10 @@ pub const GITHUB: Icon = icon_from_path!("../data/icons/github.png"); pub const VIDEO_ERROR: Icon = icon_from_path!("../data/icons/video_error.png"); +// drag and drop icons +pub const DND_ADD_NEW: Icon = icon_from_path!("../data/icons/dnd_add_new.png"); +pub const DND_ADD_TO_EXISTING: Icon = icon_from_path!("../data/icons/dnd_add_to_existing.png"); +pub const DND_MOVE: Icon = icon_from_path!("../data/icons/dnd_move.png"); + /// `>` pub const BREADCRUMBS_SEPARATOR: Icon = icon_from_path!("../data/icons/breadcrumbs_separator.png"); diff --git a/crates/viewer/re_ui/src/ui_ext.rs b/crates/viewer/re_ui/src/ui_ext.rs index d791d2edb20f..210814ac99c6 100644 --- a/crates/viewer/re_ui/src/ui_ext.rs +++ b/crates/viewer/re_ui/src/ui_ext.rs @@ -131,6 +131,21 @@ pub trait UiExt { ) } + /// Adds a non-interactive, optionally tinted small icon. + /// + /// Uses [`DesignTokens::small_icon_size`]. Returns the rect where the icon was painted. + fn small_icon(&mut self, icon: &Icon, tint: Option) -> egui::Rect { + let ui = self.ui_mut(); + let (_, rect) = ui.allocate_space(DesignTokens::small_icon_size()); + let mut image = icon.as_image(); + if let Some(tint) = tint { + image = image.tint(tint); + } + image.paint_at(ui, rect); + + rect + } + fn medium_icon_toggle_button(&mut self, icon: &Icon, selected: &mut bool) -> egui::Response { let size_points = egui::Vec2::splat(16.0); // TODO(emilk): get from design tokens diff --git a/crates/viewer/re_view_bar_chart/src/view_class.rs b/crates/viewer/re_view_bar_chart/src/view_class.rs index baa7b8eee48d..eb56be3fd3e7 100644 --- a/crates/viewer/re_view_bar_chart/src/view_class.rs +++ b/crates/viewer/re_view_bar_chart/src/view_class.rs @@ -238,9 +238,10 @@ Display a 1D tensor as a bar chart. if let Some(entity_path) = hovered_plot_item .and_then(|hovered_plot_item| plot_item_id_to_entity_path.get(&hovered_plot_item)) { - ctx.select_hovered_on_click( + ctx.handle_select_hover_drag_interactions( &response, re_viewer_context::Item::DataResult(query.view_id, entity_path.clone().into()), + false, ); } }); diff --git a/crates/viewer/re_view_dataframe/src/dataframe_ui.rs b/crates/viewer/re_view_dataframe/src/dataframe_ui.rs index 3c01daa5ddd4..056e131d3f03 100644 --- a/crates/viewer/re_view_dataframe/src/dataframe_ui.rs +++ b/crates/viewer/re_view_dataframe/src/dataframe_ui.rs @@ -261,7 +261,8 @@ impl egui_table::TableDelegate for DataframeTableDelegate<'_> { egui::Rect::from_min_size(pos, size), egui::SelectableLabel::new(is_selected, galley), ); - self.ctx.select_hovered_on_click(&response, item); + self.ctx + .handle_select_hover_drag_interactions(&response, item, false); // TODO(emilk): expand column(s) to make sure the text fits (requires egui_table fix). } @@ -319,11 +320,12 @@ impl egui_table::TableDelegate for DataframeTableDelegate<'_> { } } ColumnDescriptor::Component(component_column_descriptor) => { - self.ctx.select_hovered_on_click( + self.ctx.handle_select_hover_drag_interactions( &response, re_viewer_context::Item::ComponentPath( component_column_descriptor.component_path(), ), + false, ); } } diff --git a/crates/viewer/re_view_graph/src/ui/draw.rs b/crates/viewer/re_view_graph/src/ui/draw.rs index db9566f9778d..0916e1026372 100644 --- a/crates/viewer/re_view_graph/src/ui/draw.rs +++ b/crates/viewer/re_view_graph/src/ui/draw.rs @@ -298,9 +298,10 @@ pub fn draw_graph( let instance_path = InstancePath::instance(entity_path.clone(), instance.instance_index); - ctx.select_hovered_on_click( + ctx.handle_select_hover_drag_interactions( &response, Item::DataResult(query.view_id, instance_path.clone()), + false, ); response = response.on_hover_ui_at_pointer(|ui| { @@ -345,9 +346,10 @@ pub fn draw_graph( let resp = draw_entity_rect(ui, *rect, entity_path, &query.highlights); current_rect = current_rect.union(resp.rect); let instance_path = InstancePath::entity_all(entity_path.clone()); - ctx.select_hovered_on_click( + ctx.handle_select_hover_drag_interactions( &resp, - vec![(Item::DataResult(query.view_id, instance_path), None)].into_iter(), + Item::DataResult(query.view_id, instance_path), + false, ); } } diff --git a/crates/viewer/re_view_map/src/map_view.rs b/crates/viewer/re_view_map/src/map_view.rs index 9d98b0811be4..7db8ad45dcf8 100644 --- a/crates/viewer/re_view_map/src/map_view.rs +++ b/crates/viewer/re_view_map/src/map_view.rs @@ -481,9 +481,10 @@ fn handle_ui_interactions( }); }); - ctx.select_hovered_on_click( + ctx.handle_select_hover_drag_interactions( &map_response, Item::DataResult(query.view_id, instance_path.clone()), + false, ); // double click selects the entire entity diff --git a/crates/viewer/re_view_spatial/src/picking_ui.rs b/crates/viewer/re_view_spatial/src/picking_ui.rs index b91ebcdf5c7f..a52cf4bff691 100644 --- a/crates/viewer/re_view_spatial/src/picking_ui.rs +++ b/crates/viewer/re_view_spatial/src/picking_ui.rs @@ -209,7 +209,7 @@ pub fn picking( }); }; - ctx.select_hovered_on_click(&response, hovered_items.into_iter()); + ctx.handle_select_hover_drag_interactions(&response, hovered_items.into_iter(), false); Ok(response) } diff --git a/crates/viewer/re_view_time_series/src/view_class.rs b/crates/viewer/re_view_time_series/src/view_class.rs index a1447b57af81..f53cd3ed58d2 100644 --- a/crates/viewer/re_view_time_series/src/view_class.rs +++ b/crates/viewer/re_view_time_series/src/view_class.rs @@ -539,7 +539,7 @@ Display time series data in a plot. } }) { - ctx.select_hovered_on_click(&response, hovered); + ctx.handle_select_hover_drag_interactions(&response, hovered, false); } } diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index 08a0050fd739..8e07b53d6c7c 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -8,9 +8,9 @@ use re_smart_channel::ReceiveSet; use re_types::blueprint::components::PanelState; use re_ui::{ContextExt as _, DesignTokens}; use re_viewer_context::{ - AppOptions, ApplicationSelectionState, BlueprintUndoState, CommandSender, ComponentUiRegistry, - PlayState, RecordingConfig, StoreContext, StoreHub, SystemCommandSender as _, - ViewClassExt as _, ViewClassRegistry, ViewStates, ViewerContext, + drag_and_drop_payload_cursor_ui, AppOptions, ApplicationSelectionState, BlueprintUndoState, + CommandSender, ComponentUiRegistry, PlayState, RecordingConfig, StoreContext, StoreHub, + SystemCommandSender as _, ViewClassExt as _, ViewClassRegistry, ViewStates, ViewerContext, }; use re_viewport::ViewportUi; use re_viewport_blueprint::ui::add_view_or_container_modal_ui; @@ -214,6 +214,10 @@ impl AppState { )), ); + // The root container cannot be dragged. + let undraggable_items = + re_viewer_context::Item::Container(viewport_ui.blueprint.root_container).into(); + let applicable_entities_per_visualizer = view_class_registry.applicable_entities_for_visualizer_systems(&recording.store_id()); let indicated_entities_per_visualizer = @@ -269,6 +273,7 @@ impl AppState { render_ctx: Some(render_ctx), command_sender, focused_item, + undraggable_items: &undraggable_items, }; // We move the time at the very start of the frame, @@ -340,6 +345,7 @@ impl AppState { render_ctx: Some(render_ctx), command_sender, focused_item, + undraggable_items: &undraggable_items, }; if *show_settings_ui { @@ -506,6 +512,7 @@ impl AppState { // add_view_or_container_modal_ui(&ctx, &viewport_ui.blueprint, ui); + drag_and_drop_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/src/ui/recordings_panel.rs b/crates/viewer/re_viewer/src/ui/recordings_panel.rs index 49457eda91a4..378d0c34f9fa 100644 --- a/crates/viewer/re_viewer/src/ui/recordings_panel.rs +++ b/crates/viewer/re_viewer/src/ui/recordings_panel.rs @@ -222,7 +222,7 @@ fn app_and_its_recordings_ui( app_id.data_ui_recording(ctx, ui, UiLayout::Tooltip); }); - ctx.select_hovered_on_click(&item_response, app_item); + ctx.handle_select_hover_drag_interactions(&item_response, app_item, false); if item_response.clicked() { // Switch to this application: diff --git a/crates/viewer/re_viewer_context/src/contents.rs b/crates/viewer/re_viewer_context/src/contents.rs index 558fbe8ad941..38149f711a25 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, ViewId}; +use crate::{BlueprintId, BlueprintIdRegistry, ContainerId, ItemCollection, ViewId}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Contents { @@ -65,6 +65,14 @@ 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 new file mode 100644 index 000000000000..bec7e7e43a5f --- /dev/null +++ b/crates/viewer/re_viewer_context/src/drag_and_drop.rs @@ -0,0 +1,178 @@ +//! 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 itertools::Itertools; + +use re_ui::{ + ColorToken, Hue, + Scale::{S325, S375}, + UiExt, +}; + +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 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() { + Self::Contents { contents } + } else { + Self::Invalid + } + } +} + +impl std::fmt::Display for DragAndDropPayload { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Contents { contents } => items_to_string( + contents + .iter() + .map(|content| content.as_item()) + .collect_vec() + .iter(), + ) + .fmt(f), + + // this is not used in the UI + Self::Invalid => "invalid selection".fmt(f), + } + } +} + +/// Display the currently dragged payload as a pill in the UI. +/// +/// 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; + + 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 }; + + egui::Frame { + fill: re_ui::design_tokens().color(ColorToken::new(hue, S325)), + stroke: egui::Stroke::new( + 1.0, + re_ui::design_tokens().color(ColorToken::new(hue, S375)), + ), + rounding: (2.0).into(), + inner_margin: egui::Margin { + left: 6.0, + right: 9.0, + top: 5.0, + bottom: 4.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 { + 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::InstancePath(instance_path) | Item::DataResult(_, instance_path) => { + if instance_path.is_all() { + entity_cnt += 1; + } else { + instance_cnt += 1; + } + } + Item::ComponentPath(_) => 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(", ") +} diff --git a/crates/viewer/re_viewer_context/src/lib.rs b/crates/viewer/re_viewer_context/src/lib.rs index 2a1c89fde387..d276cb4b793b 100644 --- a/crates/viewer/re_viewer_context/src/lib.rs +++ b/crates/viewer/re_viewer_context/src/lib.rs @@ -13,6 +13,7 @@ mod component_fallbacks; mod component_ui_registry; mod contents; mod data_result_node_or_path; +mod drag_and_drop; mod file_dialog; mod image_info; mod item; @@ -52,6 +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}, file_dialog::santitize_file_name, image_info::{ColormapWithRange, ImageInfo}, item::Item, diff --git a/crates/viewer/re_viewer_context/src/selection_state.rs b/crates/viewer/re_viewer_context/src/selection_state.rs index e99457d9ad13..ec10c4833dcb 100644 --- a/crates/viewer/re_viewer_context/src/selection_state.rs +++ b/crates/viewer/re_viewer_context/src/selection_state.rs @@ -104,6 +104,15 @@ where } } +impl IntoIterator for ItemCollection { + type Item = (Item, Option); + type IntoIter = indexmap::map::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + impl ItemCollection { /// For each item in this selection, if it refers to the first element of an instance with a /// single element, resolve it to a unindexed entity path. @@ -261,6 +270,11 @@ impl ApplicationSelectionState { *self.selection_this_frame.lock() = items.into(); } + /// Extend the selection with the provided items. + pub fn extend_selection(&self, items: impl Into) { + self.selection_this_frame.lock().extend(items.into()); + } + /// Returns the current selection. pub fn selected_items(&self) -> &ItemCollection { &self.selection_previous_frame diff --git a/crates/viewer/re_viewer_context/src/test_context.rs b/crates/viewer/re_viewer_context/src/test_context.rs index 6784ebf54657..0ce951bf7c85 100644 --- a/crates/viewer/re_viewer_context/src/test_context.rs +++ b/crates/viewer/re_viewer_context/src/test_context.rs @@ -90,6 +90,8 @@ impl TestContext { hub: &Default::default(), }; + let undraggable_items = Default::default(); + let ctx = ViewerContext { app_options: &Default::default(), cache: &Default::default(), @@ -108,6 +110,7 @@ impl TestContext { render_ctx: None, command_sender: &self.command_sender, focused_item: &None, + undraggable_items: &undraggable_items, }; func(&ctx); diff --git a/crates/viewer/re_viewer_context/src/view/view_context.rs b/crates/viewer/re_viewer_context/src/view/view_context.rs index fafa901710dd..6667a893fc67 100644 --- a/crates/viewer/re_viewer_context/src/view/view_context.rs +++ b/crates/viewer/re_viewer_context/src/view/view_context.rs @@ -87,16 +87,6 @@ impl<'a> ViewContext<'a> { self.viewer_ctx.current_query() } - /// Set hover/select/focus for a given selection based on an egui response. - #[inline] - pub fn select_hovered_on_click( - &self, - response: &egui::Response, - selection: impl Into, - ) { - self.viewer_ctx.select_hovered_on_click(response, selection); - } - #[inline] pub fn lookup_query_result(&self, id: ViewId) -> &DataQueryResult { self.viewer_ctx.lookup_query_result(id) diff --git a/crates/viewer/re_viewer_context/src/viewer_context.rs b/crates/viewer/re_viewer_context/src/viewer_context.rs index 024f7fa91893..1b1d470dc7c5 100644 --- a/crates/viewer/re_viewer_context/src/viewer_context.rs +++ b/crates/viewer/re_viewer_context/src/viewer_context.rs @@ -5,6 +5,7 @@ use re_chunk_store::LatestAtQuery; use re_entity_db::entity_db::EntityDb; use re_query::StorageEngineReadGuard; +use crate::drag_and_drop::DragAndDropPayload; use crate::{ query_context::DataQueryResult, AppOptions, ApplicableEntities, ApplicationSelectionState, Caches, CommandSender, ComponentUiRegistry, IndicatedEntities, ItemCollection, PerVisualizer, @@ -79,6 +80,13 @@ pub struct ViewerContext<'a> { /// The focused item is cleared every frame, but views may react with side-effects /// 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, } impl ViewerContext<'_> { @@ -131,11 +139,22 @@ impl ViewerContext<'_> { self.rec_cfg.time_ctrl.read().current_query() } - /// Set hover/select/focus for a given selection based on an egui response. - pub fn select_hovered_on_click( + /// Consistently handle the selection, hover, drag start interactions for a given set of items. + /// + /// The `draggable` parameter controls whether a drag can be initiated from this item. When a UI + /// element represents an [`crate::Item`], one must make the call whether this element should be + /// meaningfully draggable by the users. This is ultimately a subjective decision, but some here + /// 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? + /// - Is it expected for that kind of UI element to be draggable? For example, buttons aren't + /// typically draggable. + pub fn handle_select_hover_drag_interactions( &self, response: &egui::Response, selection: impl Into, + draggable: bool, ) { re_tracing::profile_function!(); @@ -146,14 +165,38 @@ impl ViewerContext<'_> { selection_state.set_hovered(selection.clone()); } - if response.double_clicked() { + if draggable && response.drag_started() { + let mut selected_items = selection_state.selected_items().clone(); + let is_already_selected = selection + .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; + } + selection_state.set_selection(selected_items.clone()); + } + + let selection_may_be_dragged = self + .undraggable_items + .iter_items() + .all(|item| !selected_items.contains_item(item)); + + let payload = if selection_may_be_dragged { + DragAndDropPayload::from_items(&selected_items) + } else { + DragAndDropPayload::Invalid + }; + + egui::DragAndDrop::set_payload(&response.ctx, payload); + } else if response.double_clicked() { if let Some(item) = selection.first_item() { self.command_sender .send_system(crate::SystemCommand::SetFocus(item.clone())); } - } - - if response.clicked() { + } else if response.clicked() { if response.ctx.input(|i| i.modifiers.command) { selection_state.toggle_selection(selection); } else { diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index 417c313f6e4c..96382c59bd12 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -385,15 +385,21 @@ fn apply_viewport_command( {target_position_in_container}" ); - let contents_tile_id = contents_to_move.as_tile_id(); - let target_container_tile_id = blueprint_id_to_tile_id(&target_container); + // TODO(ab): the `rev()` is better preserve ordering when moving a group of items. There + // remains some ordering (and possibly insertion point error) edge cases when dragging + // multiple item within the same container. This should be addressed by egui_tiles: + // https://github.com/rerun-io/egui_tiles/issues/90 + for contents in contents_to_move.iter().rev() { + let contents_tile_id = contents.as_tile_id(); + let target_container_tile_id = blueprint_id_to_tile_id(&target_container); - bp.tree.move_tile_to_container( - contents_tile_id, - target_container_tile_id, - target_position_in_container, - true, - ); + bp.tree.move_tile_to_container( + contents_tile_id, + target_container_tile_id, + target_position_in_container, + true, + ); + } } ViewportCommand::MoveContentsToNewContainer { @@ -600,7 +606,8 @@ impl<'a> egui_tiles::Behavior for TilesDelegate<'a, '_> { &response, SelectionUpdateBehavior::OverrideSelection, ); - self.ctx.select_hovered_on_click(&response, item); + self.ctx + .handle_select_hover_drag_interactions(&response, item, false); } response diff --git a/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs b/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs index 7e235c0508c7..911e53bc3956 100644 --- a/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs +++ b/crates/viewer/re_viewport_blueprint/src/viewport_blueprint.rs @@ -572,12 +572,12 @@ impl ViewportBlueprint { /// Move the `contents` container or view to the specified target container and position. pub fn move_contents( &self, - contents: Contents, + contents_to_move: Vec, target_container: ContainerId, target_position_in_container: usize, ) { self.enqueue_command(ViewportCommand::MoveContents { - contents_to_move: contents, + contents_to_move, target_container, target_position_in_container, }); diff --git a/crates/viewer/re_viewport_blueprint/src/viewport_command.rs b/crates/viewer/re_viewport_blueprint/src/viewport_command.rs index 75faddedd3bf..8ab43a976c3c 100644 --- a/crates/viewer/re_viewport_blueprint/src/viewport_command.rs +++ b/crates/viewer/re_viewport_blueprint/src/viewport_command.rs @@ -40,7 +40,7 @@ pub enum ViewportCommand { /// Move some contents to a different container MoveContents { - contents_to_move: Contents, + contents_to_move: Vec, target_container: ContainerId, target_position_in_container: usize, }, diff --git a/examples/rust/custom_view/src/color_coordinates_view.rs b/examples/rust/custom_view/src/color_coordinates_view.rs index f01a3ff02b32..2098da981cce 100644 --- a/examples/rust/custom_view/src/color_coordinates_view.rs +++ b/examples/rust/custom_view/src/color_coordinates_view.rs @@ -278,7 +278,11 @@ fn color_space_ui( ctx.recording(), ); }); - ctx.select_hovered_on_click(&interact, Item::DataResult(query.view_id, instance)); + ctx.handle_select_hover_drag_interactions( + &interact, + Item::DataResult(query.view_id, instance), + false, + ); } }