From 76d07dbfd5e3dd4020520d402be7f87aae5f4fb7 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 30 Jan 2025 11:23:13 +0100 Subject: [PATCH 1/3] Add support for shit-click range selection in the streams tree --- Cargo.lock | 1 + crates/viewer/re_time_panel/Cargo.toml | 3 +- crates/viewer/re_time_panel/src/lib.rs | 466 +++++++++++------- .../re_time_panel/src/streams_tree_data.rs | 80 ++- 4 files changed, 377 insertions(+), 173 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 581a3f74b78e..5a0dd7f51c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6566,6 +6566,7 @@ dependencies = [ "re_log_types", "re_tracing", "re_types", + "re_types_core", "re_ui", "re_viewer_context", "re_viewport_blueprint", diff --git a/crates/viewer/re_time_panel/Cargo.toml b/crates/viewer/re_time_panel/Cargo.toml index efa8d2341ac0..bbd635ef16ea 100644 --- a/crates/viewer/re_time_panel/Cargo.toml +++ b/crates/viewer/re_time_panel/Cargo.toml @@ -25,10 +25,11 @@ re_data_ui.workspace = true re_entity_db.workspace = true re_format.workspace = true re_int_histogram.workspace = true -re_log_types.workspace = true re_log.workspace = true +re_log_types.workspace = true re_tracing.workspace = true re_types.workspace = true +re_types_core.workspace = true re_ui.workspace = true re_viewer_context.workspace = true re_viewport_blueprint.workspace = true diff --git a/crates/viewer/re_time_panel/src/lib.rs b/crates/viewer/re_time_panel/src/lib.rs index 9227bce26426..07c85483249a 100644 --- a/crates/viewer/re_time_panel/src/lib.rs +++ b/crates/viewer/re_time_panel/src/lib.rs @@ -15,28 +15,27 @@ mod time_selection_ui; use std::sync::Arc; use egui::emath::Rangef; -use egui::{pos2, Color32, CursorIcon, NumExt, Painter, PointerButton, Rect, Shape, Ui, Vec2}; - -use re_context_menu::{ - context_menu_ui_for_item, context_menu_ui_for_item_with_context, SelectionUpdateBehavior, +use egui::{ + pos2, Color32, CursorIcon, NumExt, Painter, PointerButton, Rect, Response, Shape, Ui, Vec2, }; +use re_context_menu::{context_menu_ui_for_item_with_context, SelectionUpdateBehavior}; +use re_data_ui::item_ui::guess_instance_path_icon; use re_data_ui::DataUi as _; -use re_data_ui::{item_ui::guess_instance_path_icon, sorted_component_list_for_ui}; use re_entity_db::{EntityDb, InstancePath}; use re_log_types::{ - external::re_types_core::ComponentName, ApplicationId, ComponentPath, EntityPath, - ResolvedTimeRange, TimeInt, TimeReal, TimeType, + ApplicationId, ComponentPath, EntityPath, ResolvedTimeRange, TimeInt, TimeReal, TimeType, }; use re_types::blueprint::components::PanelState; +use re_types_core::ComponentName; use re_ui::filter_widget::format_matching_text; use re_ui::{filter_widget, list_item, ContextExt as _, DesignTokens, UiExt as _}; use re_viewer_context::{ CollapseScope, HoverHighlight, Item, ItemContext, RecordingConfig, TimeControl, TimeView, - UiLayout, ViewerContext, + UiLayout, ViewerContext, VisitorControlFlow, }; use re_viewport_blueprint::ViewportBlueprint; -use crate::streams_tree_data::EntityData; +use crate::streams_tree_data::{components_for_entity, EntityData, StreamsTreeData}; use recursive_chunks_per_timeline_subscriber::PathRecursiveChunksPerTimelineStoreSubscriber; use time_axis::TimelineAxis; use time_control_ui::TimeControlUi; @@ -148,6 +147,12 @@ pub struct TimePanel { /// recording with a different application id. #[serde(skip)] filter_state_app_id: Option, + + /// Last clicked item. + /// + /// We keep track of it to implement the range selection using shift-click. + #[serde(skip)] + last_clicked_item: Option, } impl Default for TimePanel { @@ -163,6 +168,7 @@ impl Default for TimePanel { source: TimePanelSource::Recording, filter_state: Default::default(), filter_state_app_id: None, + last_clicked_item: None, } } } @@ -622,17 +628,18 @@ impl TimePanel { let filter_matcher = self.filter_state.filter(); - let entity_tree_data = + let streams_tree_data = crate::streams_tree_data::StreamsTreeData::from_source_and_filter( ctx, self.source, &filter_matcher, ); - for child in &entity_tree_data.children { + for child in &streams_tree_data.children { self.show_entity( ctx, viewport_blueprint, + &streams_tree_data, entity_db, time_ctrl, time_area_response, @@ -650,6 +657,7 @@ impl TimePanel { &mut self, ctx: &ViewerContext<'_>, viewport_blueprint: &ViewportBlueprint, + streams_tree_data: &StreamsTreeData, entity_db: &re_entity_db::EntityDb, time_ctrl: &mut TimeControl, time_area_response: &egui::Response, @@ -715,6 +723,7 @@ impl TimePanel { self.show_entity_contents( ctx, viewport_blueprint, + streams_tree_data, entity_db, time_ctrl, time_area_response, @@ -746,19 +755,15 @@ impl TimePanel { } } - context_menu_ui_for_item_with_context( + self.handle_interactions_for_item( ctx, viewport_blueprint, - &item.to_item(), - // expand/collapse context menu actions need this information - ItemContext::StreamsTree { - store_kind: self.source.into(), - filter_session_id: self.filter_state.session_id(), - }, + streams_tree_data, + entity_db, + item.to_item(), &response, - SelectionUpdateBehavior::UseSelection, + true, ); - ctx.handle_select_hover_drag_interactions(&response, item.to_item(), true); let is_closed = body_response.is_none(); let response_rect = response.rect; @@ -814,6 +819,7 @@ impl TimePanel { &mut self, ctx: &ViewerContext<'_>, viewport_blueprint: &ViewportBlueprint, + streams_tree_data: &StreamsTreeData, entity_db: &re_entity_db::EntityDb, time_ctrl: &mut TimeControl, time_area_response: &egui::Response, @@ -827,6 +833,7 @@ impl TimePanel { self.show_entity( ctx, viewport_blueprint, + streams_tree_data, entity_db, time_ctrl, time_area_response, @@ -840,164 +847,291 @@ impl TimePanel { let engine = entity_db.storage_engine(); let store = engine.store(); - // If this is an entity: - if let Some(components) = store.all_components_for_entity(entity_path) { - for component_name in sorted_component_list_for_ui(components.iter()) { - let is_static = store.entity_has_static_component(entity_path, &component_name); + for component_name in components_for_entity(store, entity_path) { + let is_static = store.entity_has_static_component(entity_path, &component_name); + + let component_path = ComponentPath::new(entity_path.clone(), component_name); + let short_component_name = component_path.component_name.short_name(); + let item = TimePanelItem::component_path(component_path.clone()); + let timeline = time_ctrl.timeline(); + + let response = ui + .list_item() + .render_offscreen(false) + .selected(ctx.selection().contains_item(&item.to_item())) + .force_hovered( + ctx.selection_state() + .highlight_for_ui_element(&item.to_item()) + == HoverHighlight::Hovered, + ) + .show_hierarchical( + ui, + list_item::LabelContent::new(short_component_name) + .with_icon(if is_static { + &re_ui::icons::COMPONENT_STATIC + } else { + &re_ui::icons::COMPONENT_TEMPORAL + }) + .truncate(false), + ); - let component_path = ComponentPath::new(entity_path.clone(), component_name); - let short_component_name = component_path.component_name.short_name(); - let item = TimePanelItem::component_path(component_path.clone()); - let timeline = time_ctrl.timeline(); + self.handle_interactions_for_item( + ctx, + viewport_blueprint, + streams_tree_data, + entity_db, + item.to_item(), + &response, + false, + ); - let response = ui - .list_item() - .render_offscreen(false) - .selected(ctx.selection().contains_item(&item.to_item())) - .force_hovered( - ctx.selection_state() - .highlight_for_ui_element(&item.to_item()) - == HoverHighlight::Hovered, - ) - .show_hierarchical( - ui, - list_item::LabelContent::new(short_component_name) - .with_icon(if is_static { - &re_ui::icons::COMPONENT_STATIC - } else { - &re_ui::icons::COMPONENT_TEMPORAL - }) - .truncate(false), - ); + let response_rect = response.rect; - context_menu_ui_for_item( - ctx, - viewport_blueprint, - &item.to_item(), - &response, - SelectionUpdateBehavior::UseSelection, + response.on_hover_ui(|ui| { + let num_static_messages = + store.num_static_events_for_component(entity_path, component_name); + let num_temporal_messages = store.num_temporal_events_for_component_on_timeline( + time_ctrl.timeline(), + entity_path, + component_name, ); - ctx.handle_select_hover_drag_interactions(&response, item.to_item(), false); - - let response_rect = response.rect; - - response.on_hover_ui(|ui| { - let num_static_messages = - store.num_static_events_for_component(entity_path, component_name); - let num_temporal_messages = store - .num_temporal_events_for_component_on_timeline( - time_ctrl.timeline(), - entity_path, - component_name, - ); - let total_num_messages = num_static_messages + num_temporal_messages; - - if total_num_messages == 0 { - ui.label(ui.ctx().warning_text(format!( - "No event logged on timeline {:?}", - timeline.name() - ))); - } else { - list_item::list_item_scope(ui, "hover tooltip", |ui| { - let kind = if is_static { "Static" } else { "Temporal" }; - - let num_messages = if is_static { - num_static_messages - } else { - num_temporal_messages - }; - - let num_messages = if num_messages == 1 { - "once".to_owned() - } else { - format!("{} times", re_format::format_uint(num_messages)) - }; - - ui.list_item() - .interactive(false) - .render_offscreen(false) - .show_flat( - ui, - list_item::LabelContent::new(format!( - "{kind} component, logged {num_messages}" - )) - .truncate(false) - .with_icon(if is_static { - &re_ui::icons::COMPONENT_STATIC - } else { - &re_ui::icons::COMPONENT_TEMPORAL - }), - ); - - // Static components are not displayed at all on the timeline, so cannot be - // previewed there. So we display their content in this tooltip instead. - // Conversely, temporal components change over time, and so showing a specific instance here - // can be confusing. - if is_static { - let query = re_chunk_store::LatestAtQuery::new( - *time_ctrl.timeline(), - TimeInt::MAX, - ); - let ui_layout = UiLayout::Tooltip; - component_path.data_ui(ctx, ui, ui_layout, &query, entity_db); - } - }); - } - }); + let total_num_messages = num_static_messages + num_temporal_messages; - self.next_col_right = self.next_col_right.max(response_rect.right()); + if total_num_messages == 0 { + ui.label(ui.ctx().warning_text(format!( + "No event logged on timeline {:?}", + timeline.name() + ))); + } else { + list_item::list_item_scope(ui, "hover tooltip", |ui| { + let kind = if is_static { "Static" } else { "Temporal" }; - // From the left of the label, all the way to the right-most of the time panel - let full_width_rect = Rect::from_x_y_ranges( - response_rect.left()..=ui.max_rect().right(), - response_rect.y_range(), - ); + let num_messages = if is_static { + num_static_messages + } else { + num_temporal_messages + }; - let is_visible = ui.is_rect_visible(full_width_rect); - - if is_visible { - let component_has_data_in_current_timeline = store - .entity_has_component_on_timeline( - time_ctrl.timeline(), - entity_path, - &component_name, - ); - - if component_has_data_in_current_timeline { - // show the data in the time area: - let row_rect = Rect::from_x_y_ranges( - time_area_response.rect.x_range(), - response_rect.y_range(), - ); - - highlight_timeline_row( - ui, - ctx, - time_area_painter, - &item.to_item(), - &row_rect, - ); - - let db = match self.source { - TimePanelSource::Recording => ctx.recording(), - TimePanelSource::Blueprint => ctx.store_context.blueprint, + let num_messages = if num_messages == 1 { + "once".to_owned() + } else { + format!("{} times", re_format::format_uint(num_messages)) }; - data_density_graph::data_density_graph_ui( - &mut self.data_density_graph_painter, - ctx, - time_ctrl, - db, - time_area_painter, - ui, - &self.time_ranges_ui, - row_rect, - &item, - true, - ); + ui.list_item() + .interactive(false) + .render_offscreen(false) + .show_flat( + ui, + list_item::LabelContent::new(format!( + "{kind} component, logged {num_messages}" + )) + .truncate(false) + .with_icon(if is_static { + &re_ui::icons::COMPONENT_STATIC + } else { + &re_ui::icons::COMPONENT_TEMPORAL + }), + ); + + // Static components are not displayed at all on the timeline, so cannot be + // previewed there. So we display their content in this tooltip instead. + // Conversely, temporal components change over time, and so showing a specific instance here + // can be confusing. + if is_static { + let query = re_chunk_store::LatestAtQuery::new( + *time_ctrl.timeline(), + TimeInt::MAX, + ); + let ui_layout = UiLayout::Tooltip; + component_path.data_ui(ctx, ui, ui_layout, &query, entity_db); + } + }); + } + }); + + self.next_col_right = self.next_col_right.max(response_rect.right()); + + // From the left of the label, all the way to the right-most of the time panel + let full_width_rect = Rect::from_x_y_ranges( + response_rect.left()..=ui.max_rect().right(), + response_rect.y_range(), + ); + + let is_visible = ui.is_rect_visible(full_width_rect); + + if is_visible { + let component_has_data_in_current_timeline = store + .entity_has_component_on_timeline( + time_ctrl.timeline(), + entity_path, + &component_name, + ); + + if component_has_data_in_current_timeline { + // show the data in the time area: + let row_rect = Rect::from_x_y_ranges( + time_area_response.rect.x_range(), + response_rect.y_range(), + ); + + highlight_timeline_row(ui, ctx, time_area_painter, &item.to_item(), &row_rect); + + let db = match self.source { + TimePanelSource::Recording => ctx.recording(), + TimePanelSource::Blueprint => ctx.store_context.blueprint, + }; + + data_density_graph::data_density_graph_ui( + &mut self.data_density_graph_painter, + ctx, + time_ctrl, + db, + time_area_painter, + ui, + &self.time_ranges_ui, + row_rect, + &item, + true, + ); + } + } + } + } + + #[expect(clippy::too_many_arguments)] + fn handle_interactions_for_item( + &mut self, + ctx: &ViewerContext<'_>, + viewport_blueprint: &ViewportBlueprint, + streams_tree_data: &StreamsTreeData, + entity_db: &re_entity_db::EntityDb, + item: Item, + response: &egui::Response, + is_draggable: bool, + ) { + context_menu_ui_for_item_with_context( + ctx, + viewport_blueprint, + &item, + // expand/collapse context menu actions need this information + ItemContext::StreamsTree { + store_kind: self.source.into(), + filter_session_id: self.filter_state.session_id(), + }, + response, + SelectionUpdateBehavior::UseSelection, + ); + ctx.handle_select_hover_drag_interactions(response, item.clone(), is_draggable); + + self.handle_range_selection(ctx, streams_tree_data, entity_db, item, response); + } + + /// Handle setting/extending the selection based on shift-clicking. + fn handle_range_selection( + &mut self, + ctx: &ViewerContext<'_>, + streams_tree_data: &StreamsTreeData, + entity_db: &re_entity_db::EntityDb, + item: Item, + response: &Response, + ) { + // Early out if we're not being clicked. + if !response.clicked() { + return; + } + + let modifiers = ctx.egui_ctx.input(|i| i.modifiers); + + if modifiers.shift { + if self.last_clicked_item.is_some() { + let items_iterator = self + .items_in_range(ctx, streams_tree_data, entity_db, &item) + .into_iter() + .map(|item| { + ( + item, + Some(ItemContext::StreamsTree { + store_kind: self.source.into(), + filter_session_id: self.filter_state.session_id(), + }), + ) + }); + + if items_iterator.len() > 0 { + if modifiers.command { + ctx.selection_state.extend_selection(items_iterator); + } else { + ctx.selection_state.set_selection(items_iterator); } } } + } else { + self.last_clicked_item = Some(item); + } + } + + /// Selects a range of items in the streams tree. + /// + /// This method selects all [`Item`]s displayed between the provided shift-clicked item and the + /// existing last-clicked item (if any). It takes into account the collapsed state, so only + /// actually visible items may be selected. + fn items_in_range( + &mut self, + ctx: &ViewerContext<'_>, + streams_tree_data: &StreamsTreeData, + entity_db: &re_entity_db::EntityDb, + shift_clicked_item: &Item, + ) -> Vec { + let mut items_in_range = vec![]; + let mut found_last_clicked_items = false; + let mut found_shift_clicked_items = false; + + streams_tree_data.visit(entity_db, |entity_data, component_name| { + let item = if let Some(component_name) = component_name { + Item::ComponentPath(ComponentPath::new( + entity_data.entity_path.clone(), + component_name, + )) + } else { + entity_data.item() + }; + + if Some(&item) == self.last_clicked_item.as_ref() { + found_last_clicked_items = true; + } + + if &item == shift_clicked_item { + found_shift_clicked_items = true; + } + + if found_last_clicked_items || found_shift_clicked_items { + items_in_range.push(item); + } + + if found_last_clicked_items && found_shift_clicked_items { + return VisitorControlFlow::Break(()); + } + + let is_expanded = entity_data + .is_open(ctx.egui_ctx, self.collapse_scope()) + .unwrap_or(false); + + if is_expanded { + VisitorControlFlow::Continue + } else { + VisitorControlFlow::SkipBranch + } + }); + + if !found_last_clicked_items { + // This can happen if the last clicked item became invisible due to collapsing, or if + // the user switched to another recording. In either case, we invalidate it. + self.last_clicked_item = None; + + vec![] + } else { + items_in_range } } diff --git a/crates/viewer/re_time_panel/src/streams_tree_data.rs b/crates/viewer/re_time_panel/src/streams_tree_data.rs index 596750fcf7f7..271649da8a69 100644 --- a/crates/viewer/re_time_panel/src/streams_tree_data.rs +++ b/crates/viewer/re_time_panel/src/streams_tree_data.rs @@ -1,14 +1,16 @@ -use std::ops::Range; +use std::ops::{ControlFlow, Range}; use itertools::Itertools as _; -use smallvec::SmallVec; -use re_entity_db::EntityTree; +use crate::TimePanelSource; +use re_chunk_store::ChunkStore; +use re_data_ui::sorted_component_list_for_ui; +use re_entity_db::{EntityTree, InstancePath}; use re_log_types::EntityPath; +use re_types_core::ComponentName; use re_ui::filter_widget::FilterMatcher; -use re_viewer_context::ViewerContext; - -use crate::TimePanelSource; +use re_viewer_context::{CollapseScope, Item, ViewerContext, VisitorControlFlow}; +use smallvec::SmallVec; #[derive(Debug)] pub struct StreamsTreeData { @@ -46,6 +48,31 @@ impl StreamsTreeData { }, } } + + /// Visit the entire tree. + /// + /// Note that we ALSO visit components, despite them not being part of the data structures. This + /// is because _currently_, we rarely need to visit, but when we do, we need to components, and + /// having them in the structure would be too expensive for the cases where it's unnecessary + /// (e.g., when the tree is collapsed). + /// + /// The provided closure is called once for each entity with `None` as component name argument. + /// Then, consistent with the display order, its children entities are visited, and then its + /// components are visited. + pub fn visit( + &self, + entity_db: &re_entity_db::EntityDb, + mut visitor: impl FnMut(&EntityData, Option) -> VisitorControlFlow, + ) -> ControlFlow { + let engine = entity_db.storage_engine(); + let store = engine.store(); + + for child in &self.children { + child.visit(store, &mut visitor)?; + } + + ControlFlow::Continue(()) + } } // --- @@ -149,4 +176,45 @@ impl EntityData { }) } } + + /// Visit this entity, included its components in the provided store. + pub fn visit( + &self, + store: &ChunkStore, + visitor: &mut impl FnMut(&Self, Option) -> VisitorControlFlow, + ) -> ControlFlow { + if visitor(self, None).visit_children()? { + for child in &self.children { + child.visit(store, visitor)?; + } + + for component_name in components_for_entity(store, &self.entity_path) { + // these cannot have children + let _ = visitor(self, Some(component_name)).visit_children()?; + } + } + + ControlFlow::Continue(()) + } + + pub fn item(&self) -> Item { + Item::InstancePath(InstancePath::entity_all(self.entity_path.clone())) + } + pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> Option { + collapse_scope + .item(self.item()) + .map(|collapse_id| collapse_id.is_open(ctx).unwrap_or(self.default_open)) + } +} + +/// Lists the components to be displayed for the given entity +pub fn components_for_entity( + store: &ChunkStore, + entity_path: &EntityPath, +) -> impl Iterator { + if let Some(components) = store.all_components_for_entity(entity_path) { + itertools::Either::Left(sorted_component_list_for_ui(components.iter()).into_iter()) + } else { + itertools::Either::Right(std::iter::empty()) + } } From 8baa0de404c258c96c8a9e8ad7c8279eb0453549 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 30 Jan 2025 15:03:16 +0100 Subject: [PATCH 2/3] lint --- crates/viewer/re_time_panel/src/streams_tree_data.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/viewer/re_time_panel/src/streams_tree_data.rs b/crates/viewer/re_time_panel/src/streams_tree_data.rs index 271649da8a69..c498cbb5a9ee 100644 --- a/crates/viewer/re_time_panel/src/streams_tree_data.rs +++ b/crates/viewer/re_time_panel/src/streams_tree_data.rs @@ -200,6 +200,7 @@ impl EntityData { pub fn item(&self) -> Item { Item::InstancePath(InstancePath::entity_all(self.entity_path.clone())) } + pub fn is_open(&self, ctx: &egui::Context, collapse_scope: CollapseScope) -> Option { collapse_scope .item(self.item()) From 4b4ea81ec23753aff478ae35f8b5f7f5c4781877 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 30 Jan 2025 16:41:41 +0100 Subject: [PATCH 3/3] review comments --- crates/viewer/re_time_panel/src/lib.rs | 47 +++++++++++++++----------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/viewer/re_time_panel/src/lib.rs b/crates/viewer/re_time_panel/src/lib.rs index 07c85483249a..ccd6451b5c04 100644 --- a/crates/viewer/re_time_panel/src/lib.rs +++ b/crates/viewer/re_time_panel/src/lib.rs @@ -148,11 +148,12 @@ pub struct TimePanel { #[serde(skip)] filter_state_app_id: Option, - /// Last clicked item. + /// Range selection anchor item. /// - /// We keep track of it to implement the range selection using shift-click. + /// This is the item we used as a starting point for range selection. It is set and remembered + /// everytime the user clicks on an item _without_ holding shift. #[serde(skip)] - last_clicked_item: Option, + range_selection_anchor_item: Option, } impl Default for TimePanel { @@ -168,7 +169,7 @@ impl Default for TimePanel { source: TimePanelSource::Recording, filter_state: Default::default(), filter_state_app_id: None, - last_clicked_item: None, + range_selection_anchor_item: None, } } } @@ -1044,21 +1045,30 @@ impl TimePanel { let modifiers = ctx.egui_ctx.input(|i| i.modifiers); if modifiers.shift { - if self.last_clicked_item.is_some() { - let items_iterator = self - .items_in_range(ctx, streams_tree_data, entity_db, &item) - .into_iter() - .map(|item| { + if let Some(anchor_item) = &self.range_selection_anchor_item { + let items_in_range = Self::items_in_range( + ctx, + streams_tree_data, + entity_db, + self.collapse_scope(), + anchor_item, + &item, + ); + + if items_in_range.is_empty() { + // This can happen if the last clicked item became invisible due to collapsing, or if + // the user switched to another recording. In either case, we invalidate it. + self.range_selection_anchor_item = None; + } else { + let items_iterator = items_in_range.into_iter().map(|item| { ( item, - Some(ItemContext::StreamsTree { - store_kind: self.source.into(), + Some(ItemContext::BlueprintTree { filter_session_id: self.filter_state.session_id(), }), ) }); - if items_iterator.len() > 0 { if modifiers.command { ctx.selection_state.extend_selection(items_iterator); } else { @@ -1067,7 +1077,7 @@ impl TimePanel { } } } else { - self.last_clicked_item = Some(item); + self.range_selection_anchor_item = Some(item); } } @@ -1077,10 +1087,11 @@ impl TimePanel { /// existing last-clicked item (if any). It takes into account the collapsed state, so only /// actually visible items may be selected. fn items_in_range( - &mut self, ctx: &ViewerContext<'_>, streams_tree_data: &StreamsTreeData, entity_db: &re_entity_db::EntityDb, + collapse_scope: CollapseScope, + anchor_item: &Item, shift_clicked_item: &Item, ) -> Vec { let mut items_in_range = vec![]; @@ -1097,7 +1108,7 @@ impl TimePanel { entity_data.item() }; - if Some(&item) == self.last_clicked_item.as_ref() { + if &item == anchor_item { found_last_clicked_items = true; } @@ -1114,7 +1125,7 @@ impl TimePanel { } let is_expanded = entity_data - .is_open(ctx.egui_ctx, self.collapse_scope()) + .is_open(ctx.egui_ctx, collapse_scope) .unwrap_or(false); if is_expanded { @@ -1125,10 +1136,6 @@ impl TimePanel { }); if !found_last_clicked_items { - // This can happen if the last clicked item became invisible due to collapsing, or if - // the user switched to another recording. In either case, we invalidate it. - self.last_clicked_item = None; - vec![] } else { items_in_range