diff --git a/crates/viewer/re_redap_browser/src/collections.rs b/crates/viewer/re_redap_browser/src/collections.rs index 880153c6f317..f4d2ee0dc512 100644 --- a/crates/viewer/re_redap_browser/src/collections.rs +++ b/crates/viewer/re_redap_browser/src/collections.rs @@ -15,7 +15,13 @@ use crate::servers::Command; /// An id for a [`Collection`]. /// //TODO(ab): this should be a properly defined id provided by the redap server #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct CollectionId(pub egui::Id); +pub struct CollectionId(egui::Id); + +impl From<&re_uri::Origin> for CollectionId { + fn from(origin: &re_uri::Origin) -> Self { + Self(egui::Id::new(origin.clone()).with("__top_level_collection__")) + } +} /// An individual collection of recordings within a catalog. pub struct Collection { @@ -142,8 +148,7 @@ async fn stream_catalog_async(origin: re_uri::Origin) -> Result f32 { + 2.0 + } + + pub fn list_header_font_size() -> f32 { + 11.0 + } + pub fn native_window_corner_radius() -> u8 { 10 } diff --git a/crates/viewer/re_ui/src/icons.rs b/crates/viewer/re_ui/src/icons.rs index 81caf67a524a..a41c6f626984 100644 --- a/crates/viewer/re_ui/src/icons.rs +++ b/crates/viewer/re_ui/src/icons.rs @@ -122,6 +122,7 @@ pub const COMPONENT_STATIC: Icon = icon_from_path!("../data/icons/component_stat pub const APPLICATION: Icon = icon_from_path!("../data/icons/application.png"); pub const DATA_SOURCE: Icon = icon_from_path!("../data/icons/data_source.png"); +pub const DATASET: Icon = icon_from_path!("../data/icons/dataset.png"); pub const RECORDING: Icon = icon_from_path!("../data/icons/recording.png"); pub const BLUEPRINT: Icon = icon_from_path!("../data/icons/blueprint.png"); diff --git a/crates/viewer/re_ui/src/list_item/label_content.rs b/crates/viewer/re_ui/src/list_item/label_content.rs index f66877ea0d81..957017575b20 100644 --- a/crates/viewer/re_ui/src/list_item/label_content.rs +++ b/crates/viewer/re_ui/src/list_item/label_content.rs @@ -1,4 +1,4 @@ -use egui::{text::TextWrapping, Align, Align2, NumExt as _, Ui}; +use egui::{text::TextWrapping, Align, Align2, NumExt as _, RichText, Ui}; use super::{ContentContext, DesiredWidth, ListItemContent}; use crate::{DesignTokens, Icon, LabelStyle}; @@ -41,6 +41,18 @@ impl<'a> LabelContent<'a> { } } + /// Render this as a header item. + /// + /// Text will be strong and smaller. + /// For best results, use this with [`super::ListItem::header`]. + pub fn header(text: impl Into) -> Self { + Self::new( + text.into() + .size(DesignTokens::list_header_font_size()) + .strong(), + ) + } + /// Set the subdued state of the item. /// /// Note: takes precedence over [`Self::weak`] if set. diff --git a/crates/viewer/re_ui/src/list_item/list_item.rs b/crates/viewer/re_ui/src/list_item/list_item.rs index 0f756bd11ed4..460766740a64 100644 --- a/crates/viewer/re_ui/src/list_item/list_item.rs +++ b/crates/viewer/re_ui/src/list_item/list_item.rs @@ -49,6 +49,7 @@ pub struct ListItem { force_background: Option, pub collapse_openness: Option, height: f32, + y_offset: f32, render_offscreen: bool, } @@ -63,6 +64,7 @@ impl Default for ListItem { force_background: None, collapse_openness: None, height: DesignTokens::list_item_height(), + y_offset: 0.0, render_offscreen: true, } } @@ -142,6 +144,24 @@ impl ListItem { self } + /// Set the item's vertical offset. + /// + /// NOTE: Can only be positive. + /// Default is 0.0. + #[inline] + pub fn with_y_offset(mut self, y_offset: f32) -> Self { + self.y_offset = y_offset; + self + } + + /// Set the item's vertical offset to `DesignTokens::list_header_vertical_offset()`. + /// For best results, use this with [`super::LabelContent::header`]. + #[inline] + pub fn header(mut self) -> Self { + self.y_offset = DesignTokens::list_header_vertical_offset(); + self + } + /// Controls whether [`Self`] calls [`ListItemContent::ui`] when the item is not currently /// visible. /// @@ -287,10 +307,16 @@ impl ListItem { force_hovered, force_background, collapse_openness, - height, + mut height, + y_offset, render_offscreen, } = self; + if y_offset != 0.0 { + ui.add_space(y_offset); + height -= y_offset; + } + let collapse_extra = if collapse_openness.is_some() { DesignTokens::collapsing_triangle_area().x + DesignTokens::text_to_icon_padding() } else { diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index 283b9a1f92e2..3f47ae083e71 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -553,6 +553,12 @@ impl App { self.state.display_mode = DisplayMode::RedapBrowser; self.command_sender.send_ui(UICommand::ExpandBlueprintPanel); } + SystemCommand::SelectRedapServer { origin } => { + self.state.redap_servers.select_server(origin); + } + SystemCommand::SelectRedapDataset { origin, dataset } => { + self.state.redap_servers.select_dataset(origin, dataset); + } SystemCommand::LoadDataSource(data_source) => { let egui_ctx = egui_ctx.clone(); diff --git a/crates/viewer/re_viewer/src/ui/recordings_panel.rs b/crates/viewer/re_viewer/src/ui/recordings_panel.rs index ad049fc93c79..ad4865e4e82c 100644 --- a/crates/viewer/re_viewer/src/ui/recordings_panel.rs +++ b/crates/viewer/re_viewer/src/ui/recordings_panel.rs @@ -5,9 +5,9 @@ use re_entity_db::EntityDb; use re_log_types::{ApplicationId, LogMsg, StoreKind}; use re_smart_channel::{ReceiveSet, SmartChannelSource}; use re_types::components::Timestamp; -use re_ui::{icons, UiExt as _}; +use re_ui::{icons, list_item, UiExt as _}; use re_viewer_context::{ - Item, StoreHub, SystemCommand, SystemCommandSender as _, UiLayout, ViewerContext, + DisplayMode, Item, StoreHub, SystemCommand, SystemCommandSender as _, UiLayout, ViewerContext, }; use crate::app_state::WelcomeScreenState; @@ -104,46 +104,43 @@ fn recording_list_ui( ui: &mut egui::Ui, welcome_screen_state: &WelcomeScreenState, ) { - let mut entity_dbs_map: BTreeMap> = BTreeMap::new(); - - // Always have a place for the welcome screen, even if there is no recordings or blueprints associated with it: - entity_dbs_map - .entry(StoreHub::welcome_screen_app_id()) - .or_default(); + // TODO(lucasmerlin): Replace String with DatasetId or whatever we come up with + let mut remote_recordings: BTreeMap>> = + BTreeMap::new(); + let mut local_recordings: BTreeMap> = BTreeMap::new(); + let mut example_recordings: BTreeMap> = BTreeMap::new(); for entity_db in ctx.store_context.bundle.entity_dbs() { // We want to show all open applications, even if they have no recordings let Some(app_id) = entity_db.app_id().cloned() else { continue; // this only happens if we haven't even started loading it, or if something is really wrong with it. }; - let recordings = entity_dbs_map.entry(app_id).or_default(); + if let Some(SmartChannelSource::RedapGrpcStream(endpoint)) = &entity_db.data_source { + let origin_recordings = remote_recordings + .entry(endpoint.origin.clone()) + .or_default(); - if entity_db.store_kind() == StoreKind::Recording { - recordings.push(entity_db); - } - } + let dataset_recordings = origin_recordings + // Currently a origin only has a single dataset, this should change soon + .entry("default".to_owned()) + .or_default(); - if let Some(entity_dbs) = entity_dbs_map.remove(&StoreHub::welcome_screen_app_id()) { - // Always show welcome screen first, if at all: - if ctx - .app_options() - .include_welcome_screen_button_in_recordings_panel - && !welcome_screen_state.hide - { - debug_assert!( - entity_dbs.is_empty(), - "There shouldn't be any recording for the welcome screen, but there are!" - ); - app_and_its_recordings_ui( - ctx, - ui, - &StoreHub::welcome_screen_app_id(), - Default::default(), - ); + if entity_db.store_kind() == StoreKind::Recording { + dataset_recordings.push(entity_db); + } + } else if entity_db.store_kind() == StoreKind::Recording { + if matches!(&entity_db.data_source, Some(SmartChannelSource::RrdHttpStream {url, ..}) if url.starts_with("https://app.rerun.io")) + { + let recordings = example_recordings.entry(app_id).or_default(); + recordings.push(entity_db); + } else { + let recordings = local_recordings.entry(app_id).or_default(); + recordings.push(entity_db); + } } } - if entity_dbs_map.is_empty() && welcome_screen_state.hide { + if local_recordings.is_empty() && welcome_screen_state.hide { ui.list_item().interactive(false).show_flat( ui, re_ui::list_item::LabelContent::new("No recordings loaded") @@ -152,90 +149,223 @@ fn recording_list_ui( ); } - for (app_id, entity_dbs) in entity_dbs_map { - app_and_its_recordings_ui(ctx, ui, &app_id, entity_dbs); + for (origin, dataset_recordings) in remote_recordings { + ui.list_item().header().show_hierarchical_with_children( + ui, + egui::Id::new(&origin), + true, + list_item::LabelContent::header(origin.host.to_string()), + |ui| { + for (dataset, entity_dbs) in dataset_recordings { + dataset_and_its_recordings_ui( + ctx, + ui, + &DatasetKind::Remote(origin.clone(), dataset.clone()), + entity_dbs, + ); + } + }, + ); + } + + if !local_recordings.is_empty() { + ui.list_item().header().show_hierarchical_with_children( + ui, + egui::Id::new("local items"), + true, + list_item::LabelContent::header("Local Recordings"), + |ui| { + for (app_id, entity_dbs) in local_recordings { + dataset_and_its_recordings_ui( + ctx, + ui, + &DatasetKind::Local(app_id.clone()), + entity_dbs, + ); + } + }, + ); + } + + // Always show welcome screen last, if at all: + if (ctx + .app_options() + .include_welcome_screen_button_in_recordings_panel + && !welcome_screen_state.hide) + || !example_recordings.is_empty() + { + let item = ui.list_item().header(); + let title = list_item::LabelContent::header("Rerun examples"); + let response = if example_recordings.is_empty() { + item.show_flat(ui, title) + } else { + item.show_hierarchical_with_children( + ui, + egui::Id::new("example items"), + true, + title, + |ui| { + for (app_id, entity_dbs) in example_recordings { + dataset_and_its_recordings_ui( + ctx, + ui, + &DatasetKind::Local(app_id.clone()), + entity_dbs, + ); + } + }, + ) + .item_response + }; + + if response.clicked() { + DatasetKind::Local(StoreHub::welcome_screen_app_id()).select(ctx); + } + } +} + +#[derive(Clone, Hash)] +enum DatasetKind { + Remote(re_uri::Origin, String), + Local(ApplicationId), +} + +impl DatasetKind { + fn name(&self) -> &str { + match self { + Self::Remote(_, dataset) => dataset, + Self::Local(app_id) => app_id.as_str(), + } + } + + fn select(&self, ctx: &ViewerContext<'_>) { + match self { + Self::Remote(origin, dataset) => { + ctx.command_sender() + .send_system(SystemCommand::SelectRedapDataset { + origin: origin.clone(), + dataset: dataset.clone(), + }); + ctx.command_sender() + .send_system(SystemCommand::ChangeDisplayMode(DisplayMode::RedapBrowser)); + } + Self::Local(app) => { + ctx.command_sender() + .send_system(re_viewer_context::SystemCommand::ActivateApp(app.clone())); + ctx.command_sender() + .send_system(SystemCommand::SetSelection(Item::AppId(app.clone()))); + } + } + } + + fn item(&self) -> Option { + match self { + Self::Remote(_, _) => None, + Self::Local(app_id) => Some(Item::AppId(app_id.clone())), + } + } + + fn is_active(&self, ctx: &ViewerContext<'_>) -> bool { + match self { + Self::Remote(origin, _dataset) => ctx + .store_context + .recording + .data_source + .as_ref() + .is_some_and(|source| match source { + SmartChannelSource::RedapGrpcStream(endpoint) => { + &endpoint.origin == origin // TODO(lucasmerlin): Also check for dataset + } + _ => false, + }), + Self::Local(app_id) => &ctx.store_context.app_id == app_id, + } + } + + fn close(&self, ctx: &ViewerContext<'_>, dbs: &Vec<&EntityDb>) { + match self { + Self::Remote(..) => { + for db in dbs { + ctx.command_sender() + .send_system(SystemCommand::CloseStore(db.store_id())); + } + } + Self::Local(app_id) => { + ctx.command_sender() + .send_system(SystemCommand::CloseApp(app_id.clone())); + } + } } } -fn app_and_its_recordings_ui( +fn dataset_and_its_recordings_ui( ctx: &ViewerContext<'_>, ui: &mut egui::Ui, - app_id: &ApplicationId, + kind: &DatasetKind, mut entity_dbs: Vec<&EntityDb>, ) { entity_dbs.sort_by_key(|entity_db| entity_db.recording_property::()); - let app_item = Item::AppId(app_id.clone()); - let selected = ctx.selection().contains_item(&app_item); + let selected = kind + .item() + .is_some_and(|i| ctx.selection().contains_item(&i)); - let app_list_item = ui.list_item().selected(selected); - let app_list_item_content = re_ui::list_item::LabelContent::new(app_id.to_string()) - .with_icon_fn(|ui, rect, visuals| { - // Color icon based on whether this is the active application or not: - let color = if &ctx.store_context.app_id == app_id { + let dataset_list_item = ui.list_item().selected(selected); + let dataset_list_item_content = + re_ui::list_item::LabelContent::new(kind.name()).with_icon_fn(|ui, rect, visuals| { + // Color icon based on whether this is the active dataset or not: + let color = if kind.is_active(ctx) { visuals.fg_stroke.color } else { ui.visuals().widgets.noninteractive.fg_stroke.color }; - icons::APPLICATION.as_image().tint(color).paint_at(ui, rect); + icons::DATASET.as_image().tint(color).paint_at(ui, rect); }); - let item_response = if app_id == &StoreHub::welcome_screen_app_id() { - // Special case: the welcome screen never has any recordings - debug_assert!( - entity_dbs.is_empty(), - "There shouldn't be any recording for the welcome screen, but there are!" - ); - app_list_item.show_hierarchical(ui, app_list_item_content) - } else { - // Normal application - let id = ui.make_persistent_id(app_id); - let app_list_item_content = app_list_item_content.with_buttons(|ui| { - // Close-button: - let resp = ui.small_icon_button(&icons::REMOVE).on_hover_text( - "Close this application and all its recordings. This cannot be undone.", - ); - if resp.clicked() { - ctx.command_sender() - .send_system(SystemCommand::CloseApp(app_id.clone())); - } - resp - }); - app_list_item - .show_hierarchical_with_children(ui, id, true, app_list_item_content, |ui| { - // Show all the recordings for this application: - if entity_dbs.is_empty() { - ui.weak("(no recordings)").on_hover_ui(|ui| { - ui.label("No recordings loaded for this application"); - }); - } else { - for entity_db in entity_dbs { - let include_app_id = false; // we already show it in the parent - entity_db_button_ui( - ctx, - ui, - entity_db, - UiLayout::SelectionPanel, - include_app_id, - ); - } + let id = ui.make_persistent_id(kind); + let app_list_item_content = dataset_list_item_content.with_buttons(|ui| { + // Close-button: + let resp = ui + .small_icon_button(&icons::REMOVE) + .on_hover_text("Close this dataset and all its recordings. This cannot be undone."); + if resp.clicked() { + kind.close(ctx, &entity_dbs); + } + resp + }); + + let mut item_response = dataset_list_item + .show_hierarchical_with_children(ui, id, true, app_list_item_content, |ui| { + // Show all the recordings for this application: + if entity_dbs.is_empty() { + ui.weak("(no recordings)").on_hover_ui(|ui| { + ui.label("No recordings loaded for this application"); + }); + } else { + for entity_db in &entity_dbs { + let include_app_id = false; // we already show it in the parent + entity_db_button_ui( + ctx, + ui, + entity_db, + UiLayout::SelectionPanel, + include_app_id, + ); } - }) - .item_response - }; + } + }) + .item_response; - let item_response = item_response.on_hover_ui(|ui| { - app_id.data_ui_recording(ctx, ui, UiLayout::Tooltip); - }); + if let DatasetKind::Local(app) = &kind { + item_response = item_response.on_hover_ui(|ui| { + app.data_ui_recording(ctx, ui, UiLayout::Tooltip); + }); - ctx.handle_select_hover_drag_interactions(&item_response, app_item, false); + ctx.handle_select_hover_drag_interactions(&item_response, Item::AppId(app.clone()), false); + } if item_response.clicked() { - // Switch to this application: - ctx.command_sender() - .send_system(re_viewer_context::SystemCommand::ActivateApp( - app_id.clone(), - )); + kind.select(ctx); } } diff --git a/crates/viewer/re_viewer_context/src/global_context/command_sender.rs b/crates/viewer/re_viewer_context/src/global_context/command_sender.rs index dbd610d66e1c..f66ea44b8b09 100644 --- a/crates/viewer/re_viewer_context/src/global_context/command_sender.rs +++ b/crates/viewer/re_viewer_context/src/global_context/command_sender.rs @@ -30,6 +30,15 @@ pub enum SystemCommand { endpoint: re_uri::CatalogEndpoint, }, + SelectRedapServer { + origin: re_uri::Origin, + }, + + SelectRedapDataset { + origin: re_uri::Origin, + dataset: String, + }, + ChangeDisplayMode(crate::DisplayMode), /// Reset the `Viewer` to the default state diff --git a/crates/viewer/re_viewer_context/src/test_context.rs b/crates/viewer/re_viewer_context/src/test_context.rs index fe31ffa45415..43f2fc20fd2b 100644 --- a/crates/viewer/re_viewer_context/src/test_context.rs +++ b/crates/viewer/re_viewer_context/src/test_context.rs @@ -475,6 +475,8 @@ impl TestContext { | SystemCommand::ClearActiveBlueprint | SystemCommand::ClearActiveBlueprintAndEnableHeuristics | SystemCommand::AddRedapServer { .. } + | SystemCommand::SelectRedapDataset { .. } + | SystemCommand::SelectRedapServer { .. } | SystemCommand::ActivateRecording(_) | SystemCommand::CloseStore(_) | SystemCommand::UndoBlueprint { .. }