Skip to content

Commit 25bb0f6

Browse files
authored
Automatically expand and scroll the blueprint tree when focusing on an item (#5482)
### What Double-clicking on an item, e.g. in a space views, focuses on that item. When that happens, the blueprint tree UI will uncollapse and scroll as needed to bring said item to view. When that item is an entity (e.g. double-clicked in the streams view), then all occurrences of that entity are uncollapsed, and the view scrolls to the first one. https://github.com/rerun-io/rerun/assets/49431240/93b54fb6-ad52-4880-babd-d1001732c4db ### Checklist * [x] I have read and agree to [Contributor Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md) * [x] I've included a screenshot or gif (if applicable) * [x] I have tested the web demo (if applicable): * Using newly built examples: [app.rerun.io](https://app.rerun.io/pr/5482/index.html) * Using examples from latest `main` build: [app.rerun.io](https://app.rerun.io/pr/5482/index.html?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) * Using full set of examples from `nightly` build: [app.rerun.io](https://app.rerun.io/pr/5482/index.html?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) * [x] The PR title and labels are set such as to maximize their usefulness for the next release's CHANGELOG * [x] If applicable, add a new check to the [release checklist](https://github.com/rerun-io/rerun/blob/main/tests/python/release_checklist)! - [PR Build Summary](https://build.rerun.io/pr/5482) - [Docs preview](https://rerun.io/preview/ce5c582ffbe0844840846373a467a6907fb7b895/docs) <!--DOCS-PREVIEW--> - [Examples preview](https://rerun.io/preview/ce5c582ffbe0844840846373a467a6907fb7b895/examples) <!--EXAMPLES-PREVIEW--> - [Recent benchmark results](https://build.rerun.io/graphs/crates.html) - [Wasm size tracking](https://build.rerun.io/graphs/sizes.html)
1 parent a16b61a commit 25bb0f6

File tree

7 files changed

+234
-12
lines changed

7 files changed

+234
-12
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/re_viewport/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ nohash-hasher.workspace = true
4949
once_cell.workspace = true
5050
rayon.workspace = true
5151
rmp-serde.workspace = true
52+
smallvec.workspace = true

crates/re_viewport/src/context_menu/actions/collapse_expand_all.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ impl ContextMenuAction for CollapseExpandAllAction {
4646

4747
fn process_container(&self, ctx: &ContextMenuContext<'_>, container_id: &ContainerId) {
4848
ctx.viewport_blueprint
49-
.visit_contents_in_container(container_id, &mut |contents| match contents {
49+
.visit_contents_in_container(container_id, &mut |contents, _| match contents {
5050
Contents::Container(container_id) => CollapseScope::BlueprintTree
5151
.container(*container_id)
5252
.set_open(&ctx.egui_context, self.open()),

crates/re_viewport/src/viewport.rs

+7
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ pub struct ViewportState {
4848
///
4949
/// See [`ViewportState::is_candidate_drop_parent_container`] for details.
5050
candidate_drop_parent_container_id: Option<ContainerId>,
51+
52+
/// The item that should be focused on in the blueprint tree.
53+
///
54+
/// Set at each frame by [`Viewport::tree_ui`]. This is similar to
55+
/// [`ViewerContext::focused_item`] but account for how specifically the blueprint tree should
56+
/// handle the focused item.
57+
pub(crate) blueprint_tree_scroll_to_item: Option<Item>,
5158
}
5259

5360
static DEFAULT_PROPS: Lazy<EntityPropertyMap> = Lazy::<EntityPropertyMap>::new(Default::default);

crates/re_viewport/src/viewport_blueprint.rs

+31-6
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
33

44
use ahash::HashMap;
55
use egui_tiles::{SimplificationOptions, TileId};
6-
76
use nohash_hasher::IntSet;
7+
use smallvec::SmallVec;
8+
89
use re_data_store::LatestAtQuery;
910
use re_entity_db::EntityPath;
1011
use re_log_types::hash::Hash64;
@@ -442,26 +443,50 @@ impl ViewportBlueprint {
442443
)
443444
}
444445

446+
/// Walk the entire [`Contents`] tree, starting from the root container.
447+
///
448+
/// See [`Self::visit_contents_in_container`] for details.
449+
pub fn visit_contents(&self, visitor: &mut impl FnMut(&Contents, &SmallVec<[ContainerId; 4]>)) {
450+
if let Some(root_container) = self.root_container {
451+
self.visit_contents_in_container(&root_container, visitor);
452+
}
453+
}
454+
445455
/// Walk the subtree defined by the provided container id and call `visitor` for each
446456
/// [`Contents`].
447457
///
448-
/// Note: `visitor` is first called for the container passed in argument.
458+
/// Note:
459+
/// - `visitor` is first called for the container passed in argument
460+
/// - `visitor`'s second argument contains the hierarchy leading to the visited contents, from
461+
/// (and including) the container passed in argument
449462
pub fn visit_contents_in_container(
450463
&self,
451464
container_id: &ContainerId,
452-
visitor: &mut impl FnMut(&Contents),
465+
visitor: &mut impl FnMut(&Contents, &SmallVec<[ContainerId; 4]>),
466+
) {
467+
let mut hierarchy = SmallVec::new();
468+
self.visit_contents_in_container_impl(container_id, &mut hierarchy, visitor);
469+
}
470+
471+
fn visit_contents_in_container_impl(
472+
&self,
473+
container_id: &ContainerId,
474+
hierarchy: &mut SmallVec<[ContainerId; 4]>,
475+
visitor: &mut impl FnMut(&Contents, &SmallVec<[ContainerId; 4]>),
453476
) {
454-
visitor(&Contents::Container(*container_id));
477+
visitor(&Contents::Container(*container_id), hierarchy);
455478
if let Some(container) = self.container(container_id) {
479+
hierarchy.push(*container_id);
456480
for contents in &container.contents {
457-
visitor(contents);
481+
visitor(contents, hierarchy);
458482
match contents {
459483
Contents::Container(container_id) => {
460-
self.visit_contents_in_container(container_id, visitor);
484+
self.visit_contents_in_container_impl(container_id, hierarchy, visitor);
461485
}
462486
Contents::SpaceView(_) => {}
463487
}
464488
}
489+
hierarchy.pop();
465490
}
466491
}
467492

crates/re_viewport/src/viewport_blueprint_ui.rs

+136-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use egui::{Response, Ui};
22
use itertools::Itertools;
33
use re_data_ui::item_ui::guess_instance_path_icon;
4+
use smallvec::SmallVec;
45

56
use re_entity_db::InstancePath;
67
use re_log_types::EntityPath;
@@ -54,14 +55,16 @@ impl<'a> DataResultNodeOrPath<'a> {
5455

5556
impl Viewport<'_, '_> {
5657
/// Show the blueprint panel tree view.
57-
pub fn tree_ui(&self, ctx: &ViewerContext<'_>, ui: &mut egui::Ui) {
58+
pub fn tree_ui(&mut self, ctx: &ViewerContext<'_>, ui: &mut egui::Ui) {
5859
re_tracing::profile_function!();
5960

6061
egui::ScrollArea::both()
6162
.id_source("blueprint_tree_scroll_area")
6263
.auto_shrink([true, false])
6364
.show(ui, |ui| {
6465
ctx.re_ui.panel_content(ui, |_, ui| {
66+
self.state.blueprint_tree_scroll_to_item = self.handle_focused_item(ctx, ui);
67+
6568
self.root_container_tree_ui(ctx, ui);
6669

6770
let empty_space_response =
@@ -81,6 +84,131 @@ impl Viewport<'_, '_> {
8184
});
8285
}
8386

87+
/// Expend all required items and compute which item we should scroll to.
88+
fn handle_focused_item(&self, ctx: &ViewerContext<'_>, ui: &egui::Ui) -> Option<Item> {
89+
let focused_item = ctx.focused_item.as_ref()?;
90+
match focused_item {
91+
Item::Container(container_id) => {
92+
self.expand_all_contents_until(ui.ctx(), &Contents::Container(*container_id));
93+
Some(focused_item.clone())
94+
}
95+
Item::SpaceView(space_view_id) => {
96+
self.expand_all_contents_until(ui.ctx(), &Contents::SpaceView(*space_view_id));
97+
ctx.focused_item.clone()
98+
}
99+
Item::DataResult(space_view_id, instance_path) => {
100+
self.expand_all_contents_until(ui.ctx(), &Contents::SpaceView(*space_view_id));
101+
self.expand_all_data_results_until(
102+
ctx,
103+
ui.ctx(),
104+
space_view_id,
105+
&instance_path.entity_path,
106+
);
107+
108+
ctx.focused_item.clone()
109+
}
110+
Item::InstancePath(instance_path) => {
111+
let space_view_ids =
112+
self.list_space_views_with_entity(ctx, &instance_path.entity_path);
113+
114+
// focus on the first matching data result
115+
let res = space_view_ids
116+
.first()
117+
.map(|id| Item::DataResult(*id, instance_path.clone()));
118+
119+
for space_view_id in space_view_ids {
120+
self.expand_all_contents_until(ui.ctx(), &Contents::SpaceView(space_view_id));
121+
self.expand_all_data_results_until(
122+
ctx,
123+
ui.ctx(),
124+
&space_view_id,
125+
&instance_path.entity_path,
126+
);
127+
}
128+
129+
res
130+
}
131+
132+
Item::StoreId(_) | Item::ComponentPath(_) => None,
133+
}
134+
}
135+
136+
/// Expand all containers until reaching the provided content.
137+
fn expand_all_contents_until(&self, egui_ctx: &egui::Context, focused_contents: &Contents) {
138+
//TODO(ab): this could look nicer if `Contents` was declared in re_view_context :)
139+
let expend_contents = |contents: &Contents| match contents {
140+
Contents::Container(container_id) => CollapseScope::BlueprintTree
141+
.container(*container_id)
142+
.set_open(egui_ctx, true),
143+
Contents::SpaceView(space_view_id) => CollapseScope::BlueprintTree
144+
.space_view(*space_view_id)
145+
.set_open(egui_ctx, true),
146+
};
147+
148+
self.blueprint.visit_contents(&mut |contents, hierarchy| {
149+
if contents == focused_contents {
150+
expend_contents(contents);
151+
for parent in hierarchy {
152+
expend_contents(&Contents::Container(*parent));
153+
}
154+
}
155+
});
156+
}
157+
158+
/// List all space views that have the provided entity as data result.
159+
#[inline]
160+
fn list_space_views_with_entity(
161+
&self,
162+
ctx: &ViewerContext<'_>,
163+
entity_path: &EntityPath,
164+
) -> SmallVec<[SpaceViewId; 4]> {
165+
let mut space_view_ids = SmallVec::new();
166+
self.blueprint.visit_contents(&mut |contents, _| {
167+
if let Contents::SpaceView(space_view_id) = contents {
168+
let result_tree = &ctx.lookup_query_result(*space_view_id).tree;
169+
if result_tree.lookup_node_by_path(entity_path).is_some() {
170+
space_view_ids.push(*space_view_id);
171+
}
172+
}
173+
});
174+
space_view_ids
175+
}
176+
177+
/// Expand data results of the provided space view all the way to the provided entity.
178+
#[allow(clippy::unused_self)]
179+
fn expand_all_data_results_until(
180+
&self,
181+
ctx: &ViewerContext<'_>,
182+
egui_ctx: &egui::Context,
183+
space_view_id: &SpaceViewId,
184+
entity_path: &EntityPath,
185+
) {
186+
let result_tree = &ctx.lookup_query_result(*space_view_id).tree;
187+
if result_tree.lookup_node_by_path(entity_path).is_some() {
188+
if let Some(root_node) = result_tree.root_node() {
189+
EntityPath::incremental_walk(Some(&root_node.data_result.entity_path), entity_path)
190+
.chain(std::iter::once(root_node.data_result.entity_path.clone()))
191+
.for_each(|entity_path| {
192+
CollapseScope::BlueprintTree
193+
.data_result(*space_view_id, entity_path)
194+
.set_open(egui_ctx, true);
195+
});
196+
}
197+
}
198+
}
199+
200+
/// Check if the provided item should be scrolled to.
201+
fn scroll_to_me_if_needed(&self, ui: &egui::Ui, item: &Item, response: &egui::Response) {
202+
if Some(item) == self.state.blueprint_tree_scroll_to_item.as_ref() {
203+
// Scroll only if the entity isn't already visible. This is important because that's what
204+
// happens when double-clicking an entity _in the blueprint tree_. In such case, it would be
205+
// annoying to induce a scroll motion.
206+
if !ui.clip_rect().contains_rect(response.rect) {
207+
response.scroll_to_me(Some(egui::Align::Center));
208+
}
209+
}
210+
}
211+
84212
/// If a group or spaceview has a total of this number of elements, show its subtree by default?
85213
fn default_open_for_data_result(group: &DataResultNode) -> bool {
86214
let num_children = group.children.len();
@@ -145,6 +273,7 @@ impl Viewport<'_, '_> {
145273
&item_response,
146274
SelectionUpdateBehavior::UseSelection,
147275
);
276+
self.scroll_to_me_if_needed(ui, &item, &item_response);
148277
ctx.select_hovered_on_click(&item_response, item);
149278

150279
self.handle_root_container_drag_and_drop_interaction(
@@ -218,6 +347,7 @@ impl Viewport<'_, '_> {
218347
&response,
219348
SelectionUpdateBehavior::UseSelection,
220349
);
350+
self.scroll_to_me_if_needed(ui, &item, &response);
221351
ctx.select_hovered_on_click(&response, item);
222352

223353
self.blueprint
@@ -304,15 +434,15 @@ impl Viewport<'_, '_> {
304434
);
305435

306436
// Show 'projections' if there's any items that weren't part of the tree under origin but are directly included.
307-
// The later is important since `+ image/camera/**` necessarily has `image` and `image/camera` in the data result tree.
437+
// The latter is important since `+ image/camera/**` necessarily has `image` and `image/camera` in the data result tree.
308438
let mut projections = Vec::new();
309439
result_tree.visit(&mut |node| {
310440
if node
311441
.data_result
312442
.entity_path
313443
.starts_with(&space_view.space_origin)
314444
{
315-
false // If its under the origin, we're not interested, stop recursing.
445+
false // If it's under the origin, we're not interested, stop recursing.
316446
} else if node.data_result.tree_prefix_only {
317447
true // Keep recursing until we find a projection.
318448
} else {
@@ -351,6 +481,7 @@ impl Viewport<'_, '_> {
351481
&response,
352482
SelectionUpdateBehavior::UseSelection,
353483
);
484+
self.scroll_to_me_if_needed(ui, &item, &response);
354485
ctx.select_hovered_on_click(&response, item);
355486

356487
let content = Contents::SpaceView(*space_view_id);
@@ -474,8 +605,7 @@ impl Viewport<'_, '_> {
474605
let response = list_item
475606
.show_collapsing(
476607
ui,
477-
CollapseScope::BlueprintTree
478-
.data_result(space_view.id, node.data_result.entity_path.clone()),
608+
CollapseScope::BlueprintTree.data_result(space_view.id, entity_path.clone()),
479609
default_open,
480610
|_, ui| {
481611
for child in node.children.iter().sorted_by_key(|c| {
@@ -526,6 +656,7 @@ impl Viewport<'_, '_> {
526656
&response,
527657
SelectionUpdateBehavior::UseSelection,
528658
);
659+
self.scroll_to_me_if_needed(ui, &item, &response);
529660
ctx.select_hovered_on_click(&response, item);
530661
}
531662

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from argparse import Namespace
5+
from uuid import uuid4
6+
7+
import rerun as rr
8+
9+
README = """
10+
# Focus checks
11+
12+
## Preparation
13+
14+
TODO(ab): automate this with blueprints
15+
TODO(ab): add lots of stuff via blueprint to make the tree more crowded and check scrolling
16+
17+
- Reset the blueprint
18+
- Clone the 3D space view such as to have 2 of them.
19+
20+
## Checks
21+
22+
- Collapse all in the blueprint tree.
23+
- Double-click on the box in the first space view, check corresponding space view expands.
24+
- Collapse all in the blueprint tree.
25+
- Double-click on the leaf "boxes3d" entity in the streams view, check both space views expand.
26+
"""
27+
28+
29+
def log_readme() -> None:
30+
rr.log("readme", rr.TextDocument(README, media_type=rr.MediaType.MARKDOWN), timeless=True)
31+
32+
33+
def log_some_space_views() -> None:
34+
rr.set_time_sequence("frame_nr", 0)
35+
36+
rr.log(
37+
"/objects/boxes/boxes3d",
38+
rr.Boxes3D(centers=[[0, 0, 0], [1, 1.5, 1.15], [3, 2, 1]], half_sizes=[0.5, 1, 0.5] * 3),
39+
)
40+
41+
42+
def run(args: Namespace) -> None:
43+
# TODO(cmc): I have no idea why this works without specifying a `recording_id`, but
44+
# I'm not gonna rely on it anyway.
45+
rr.script_setup(args, f"{os.path.basename(__file__)}", recording_id=uuid4())
46+
47+
log_readme()
48+
log_some_space_views()
49+
50+
51+
if __name__ == "__main__":
52+
import argparse
53+
54+
parser = argparse.ArgumentParser(description="Interactive release checklist")
55+
rr.script_add_args(parser)
56+
args = parser.parse_args()
57+
run(args)

0 commit comments

Comments
 (0)