Skip to content

Commit 6583381

Browse files
authored
Add keyboard navigation to the blueprint and streams tree (#9960)
### Related * Part of #3055 ### What Now in both the blueprint tree and streams tree: - left/right collapses/uncollapses the selected item - up/down selects the previous/next item **Notes**: - This only works with single selection. - This also fix a bug where shift-select could be broken by "collapsing all" on the root container. - Previously, the left/right arrows were used to move the time cursor. This now requires cmd/alt-left/right. Also, the go to start/end of timeline is now alt-left/right.
1 parent a1b1de6 commit 6583381

File tree

6 files changed

+309
-41
lines changed

6 files changed

+309
-41
lines changed

crates/viewer/re_blueprint_tree/src/blueprint_tree.rs

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::ops::ControlFlow;
2+
13
use egui::{Response, Ui};
24
use smallvec::SmallVec;
35

@@ -55,6 +57,12 @@ pub struct BlueprintTree {
5557
/// This is the item we used as a starting point for range selection. It is set and remembered
5658
/// everytime the user clicks on an item _without_ holding shift.
5759
range_selection_anchor_item: Option<Item>,
60+
61+
/// Used when the selection is modified using key navigation.
62+
///
63+
/// IMPORTANT: Always make sure that the item will be drawn this or next frame when setting this
64+
/// to `Some`, so that this flag is immediately consumed.
65+
scroll_to_me_item: Option<Item>,
5866
}
5967

6068
impl BlueprintTree {
@@ -186,6 +194,14 @@ impl BlueprintTree {
186194
) {
187195
let item = Item::Container(container_data.id);
188196

197+
// It's possible that the root becomes technically collapsed (e.g. context menu or arrow
198+
// navigation), even though we don't allow that in the ui. We really don't want that,
199+
// though, because it breaks the collapse-based tree data visiting. To avoid that, we always
200+
// force uncollapse this item.
201+
self.collapse_scope()
202+
.container(container_data.id)
203+
.set_open(ctx.egui_ctx(), true);
204+
189205
let item_response = ui
190206
.list_item()
191207
.render_offscreen(false)
@@ -218,7 +234,7 @@ impl BlueprintTree {
218234
viewport_blueprint,
219235
blueprint_tree_data,
220236
ui,
221-
item,
237+
&item,
222238
&item_response,
223239
);
224240

@@ -336,7 +352,7 @@ impl BlueprintTree {
336352
viewport_blueprint,
337353
blueprint_tree_data,
338354
ui,
339-
item,
355+
&item,
340356
&response,
341357
);
342358

@@ -448,7 +464,7 @@ impl BlueprintTree {
448464
viewport_blueprint,
449465
blueprint_tree_data,
450466
ui,
451-
item,
467+
&item,
452468
&response,
453469
);
454470

@@ -618,7 +634,7 @@ impl BlueprintTree {
618634
viewport_blueprint,
619635
blueprint_tree_data,
620636
ui,
621-
item,
637+
&item,
622638
&response,
623639
);
624640
}
@@ -632,24 +648,119 @@ impl BlueprintTree {
632648
viewport_blueprint: &ViewportBlueprint,
633649
blueprint_tree_data: &BlueprintTreeData,
634650
ui: &egui::Ui,
635-
item: Item,
651+
item: &Item,
636652
response: &Response,
637653
) {
638654
context_menu_ui_for_item_with_context(
639655
ctx,
640656
viewport_blueprint,
641-
&item,
657+
item,
642658
// expand/collapse context menu actions need this information
643659
ItemContext::BlueprintTree {
644660
filter_session_id: self.filter_state.session_id(),
645661
},
646662
response,
647663
SelectionUpdateBehavior::UseSelection,
648664
);
649-
self.scroll_to_me_if_needed(ui, &item, response);
665+
self.scroll_to_me_if_needed(ui, item, response);
650666
ctx.handle_select_hover_drag_interactions(response, item.clone(), true);
651667

652-
self.handle_range_selection(ctx, blueprint_tree_data, item, response);
668+
self.handle_range_selection(ctx, blueprint_tree_data, item.clone(), response);
669+
670+
self.handle_key_navigation(ctx, blueprint_tree_data, item);
671+
}
672+
673+
fn handle_key_navigation(
674+
&mut self,
675+
ctx: &ViewerContext<'_>,
676+
blueprint_tree_data: &BlueprintTreeData,
677+
item: &Item,
678+
) {
679+
if ctx.selection_state().selected_items().single_item() != Some(item) {
680+
return;
681+
}
682+
683+
if ctx
684+
.egui_ctx()
685+
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight))
686+
{
687+
if let Some(collapse_id) = self.collapse_scope().item(item.clone()) {
688+
collapse_id.set_open(ctx.egui_ctx(), true);
689+
}
690+
}
691+
692+
if ctx
693+
.egui_ctx()
694+
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft))
695+
{
696+
if let Some(collapse_id) = self.collapse_scope().item(item.clone()) {
697+
collapse_id.set_open(ctx.egui_ctx(), false);
698+
}
699+
}
700+
701+
if ctx
702+
.egui_ctx()
703+
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
704+
{
705+
let mut found_current = false;
706+
707+
let result = blueprint_tree_data.visit(|tree_item| {
708+
let is_item_collapsed = !tree_item.is_open(ctx.egui_ctx(), self.collapse_scope());
709+
710+
if &tree_item.item() == item {
711+
found_current = true;
712+
713+
return if is_item_collapsed {
714+
VisitorControlFlow::SkipBranch
715+
} else {
716+
VisitorControlFlow::Continue
717+
};
718+
}
719+
720+
if found_current {
721+
VisitorControlFlow::Break(Some(tree_item.item()))
722+
} else if is_item_collapsed {
723+
VisitorControlFlow::SkipBranch
724+
} else {
725+
VisitorControlFlow::Continue
726+
}
727+
});
728+
729+
if let ControlFlow::Break(Some(item)) = result {
730+
ctx.selection_state().set_selection(item.clone());
731+
self.scroll_to_me_item = Some(item.clone());
732+
self.range_selection_anchor_item = Some(item);
733+
}
734+
}
735+
736+
if ctx
737+
.egui_ctx()
738+
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
739+
{
740+
let mut last_item = None;
741+
742+
let result = blueprint_tree_data.visit(|tree_item| {
743+
let is_item_collapsed = !tree_item.is_open(ctx.egui_ctx(), self.collapse_scope());
744+
745+
if &tree_item.item() == item {
746+
return VisitorControlFlow::Break(last_item.clone());
747+
}
748+
749+
last_item = Some(tree_item.item());
750+
751+
if is_item_collapsed {
752+
VisitorControlFlow::SkipBranch
753+
} else {
754+
VisitorControlFlow::Continue
755+
}
756+
});
757+
758+
if let ControlFlow::Break(Some(item)) = result {
759+
ctx.selection_state().set_selection(item.clone());
760+
self.scroll_to_me_item = Some(item.clone());
761+
self.range_selection_anchor_item = Some(item);
762+
}
763+
}
653764
}
654765

655766
/// Handle setting/extending the selection based on shift-clicking.
@@ -738,9 +849,7 @@ impl BlueprintTree {
738849
return VisitorControlFlow::Break(());
739850
}
740851

741-
let is_expanded = blueprint_tree_item
742-
.is_open(ctx.egui_ctx(), collapse_scope)
743-
.unwrap_or(false);
852+
let is_expanded = blueprint_tree_item.is_open(ctx.egui_ctx(), collapse_scope);
744853

745854
if is_expanded {
746855
VisitorControlFlow::Continue
@@ -757,7 +866,7 @@ impl BlueprintTree {
757866
}
758867

759868
/// Check if the provided item should be scrolled to.
760-
fn scroll_to_me_if_needed(&self, ui: &egui::Ui, item: &Item, response: &egui::Response) {
869+
fn scroll_to_me_if_needed(&mut self, ui: &egui::Ui, item: &Item, response: &egui::Response) {
761870
if Some(item) == self.blueprint_tree_scroll_to_item.as_ref() {
762871
// Scroll only if the entity isn't already visible. This is important because that's what
763872
// happens when double-clicking an entity _in the blueprint tree_. In such case, it would be
@@ -766,6 +875,13 @@ impl BlueprintTree {
766875
response.scroll_to_me(Some(egui::Align::Center));
767876
}
768877
}
878+
879+
if Some(item) == self.scroll_to_me_item.as_ref() {
880+
// This is triggered by keyboard navigation, so in this case we just want to scroll
881+
// minimally for the item to be visible.
882+
response.scroll_to_me(None);
883+
self.scroll_to_me_item = None;
884+
}
769885
}
770886

771887
// ----------------------------------------------------------------------------

crates/viewer/re_blueprint_tree/src/data.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,8 +628,8 @@ impl BlueprintTreeItem<'_> {
628628
}
629629
}
630630

631-
pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> Option<bool> {
632-
collapse_scope.item(self.item()).map(|collapse_id| {
631+
pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> bool {
632+
collapse_scope.item(self.item()).is_some_and(|collapse_id| {
633633
collapse_id
634634
.is_open(ctx)
635635
.unwrap_or_else(|| self.default_open())

crates/viewer/re_time_panel/src/streams_tree_data.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use smallvec::SmallVec;
77
use re_chunk_store::ChunkStore;
88
use re_data_ui::sorted_component_list_for_ui;
99
use re_entity_db::{EntityTree, InstancePath};
10-
use re_log_types::EntityPath;
10+
use re_log_types::{ComponentPath, EntityPath};
1111
use re_ui::filter_widget::{FilterMatcher, PathRanges};
1212
use re_viewer_context::{CollapseScope, Item, ViewerContext, VisitorControlFlow};
1313

@@ -61,17 +61,17 @@ impl StreamsTreeData {
6161
/// Visit the entire tree.
6262
///
6363
/// Note that we ALSO visit components, despite them not being part of the data structures. This
64-
/// is because _currently_, we rarely need to visit, but when we do, we need to components, and
64+
/// is because _currently_, we rarely need to visit, but when we do, we need components, and
6565
/// having them in the structure would be too expensive for the cases where it's unnecessary
6666
/// (e.g., when the tree is collapsed).
6767
///
68-
/// The provided closure is called once for each entity with `None` as component name argument.
68+
/// The provided closure is called once for each entity with `None` as component argument.
6969
/// Then, consistent with the display order, its children entities are visited, and then its
7070
/// components are visited.
7171
pub fn visit<B>(
7272
&self,
7373
entity_db: &re_entity_db::EntityDb,
74-
mut visitor: impl FnMut(&EntityData, Option<ComponentDescriptor>) -> VisitorControlFlow<B>,
74+
mut visitor: impl FnMut(EntityOrComponentData<'_>) -> VisitorControlFlow<B>,
7575
) -> ControlFlow<B> {
7676
let engine = entity_db.storage_engine();
7777
let store = engine.store();
@@ -214,16 +214,20 @@ impl EntityData {
214214
pub fn visit<B>(
215215
&self,
216216
store: &ChunkStore,
217-
visitor: &mut impl FnMut(&Self, Option<ComponentDescriptor>) -> VisitorControlFlow<B>,
217+
visitor: &mut impl FnMut(EntityOrComponentData<'_>) -> VisitorControlFlow<B>,
218218
) -> ControlFlow<B> {
219-
if visitor(self, None).visit_children()? {
219+
if visitor(EntityOrComponentData::Entity(self)).visit_children()? {
220220
for child in &self.children {
221221
child.visit(store, visitor)?;
222222
}
223223

224-
for component_descr in components_for_entity(store, &self.entity_path) {
224+
for component_descriptor in components_for_entity(store, &self.entity_path) {
225225
// these cannot have children
226-
let _ = visitor(self, Some(component_descr)).visit_children()?;
226+
let _ = visitor(EntityOrComponentData::Component {
227+
entity_data: self,
228+
component_descriptor,
229+
})
230+
.visit_children()?;
227231
}
228232
}
229233

@@ -234,10 +238,10 @@ impl EntityData {
234238
Item::InstancePath(InstancePath::entity_all(self.entity_path.clone()))
235239
}
236240

237-
pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> Option<bool> {
241+
pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> bool {
238242
collapse_scope
239243
.item(self.item())
240-
.map(|collapse_id| collapse_id.is_open(ctx).unwrap_or(self.default_open))
244+
.is_some_and(|collapse_id| collapse_id.is_open(ctx).unwrap_or(self.default_open))
241245
}
242246
}
243247

@@ -252,3 +256,36 @@ pub fn components_for_entity(
252256
itertools::Either::Right(std::iter::empty())
253257
}
254258
}
259+
260+
// ---
261+
262+
#[derive(Debug)]
263+
pub enum EntityOrComponentData<'a> {
264+
Entity(&'a EntityData),
265+
Component {
266+
entity_data: &'a EntityData,
267+
component_descriptor: ComponentDescriptor,
268+
},
269+
}
270+
271+
impl EntityOrComponentData<'_> {
272+
pub fn item(&self) -> Item {
273+
match self {
274+
Self::Entity(entity_data) => entity_data.item(),
275+
Self::Component {
276+
entity_data,
277+
component_descriptor,
278+
} => Item::ComponentPath(ComponentPath::new(
279+
entity_data.entity_path.clone(),
280+
component_descriptor.clone(),
281+
)),
282+
}
283+
}
284+
285+
pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> bool {
286+
match self {
287+
Self::Entity(entity_data) => entity_data.is_open(ctx, collapse_scope),
288+
Self::Component { .. } => true,
289+
}
290+
}
291+
}

0 commit comments

Comments
 (0)