diff --git a/Cargo.lock b/Cargo.lock index 8f5ca2d7f830..26b81d361d4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10263,6 +10263,7 @@ dependencies = [ "eframe", "egui", "egui-wgpu", + "egui_extras", "egui_kittest", "egui_plot", "ehttp", diff --git a/crates/utils/re_auth/src/oauth.rs b/crates/utils/re_auth/src/oauth.rs index 17fcfaeeea67..0e0d24058bec 100644 --- a/crates/utils/re_auth/src/oauth.rs +++ b/crates/utils/re_auth/src/oauth.rs @@ -48,6 +48,16 @@ pub fn load_credentials() -> Result, CredentialsLoadError> { } } +#[derive(Debug, thiserror::Error)] +#[error("failed to load credentials: {0}")] +pub struct CredentialsClearError(#[from] storage::ClearError); + +pub fn clear_credentials() -> Result<(), CredentialsClearError> { + storage::clear()?; + + Ok(()) +} + #[derive(Debug, thiserror::Error)] pub enum CredentialsRefreshError { #[error("failed to refresh credentials: {0}")] diff --git a/crates/utils/re_auth/src/oauth/storage.rs b/crates/utils/re_auth/src/oauth/storage.rs index af24a2b61df8..67128265a420 100644 --- a/crates/utils/re_auth/src/oauth/storage.rs +++ b/crates/utils/re_auth/src/oauth/storage.rs @@ -36,15 +36,29 @@ pub enum StoreError { NoLocalStorage, } +#[derive(Debug, thiserror::Error)] +pub enum ClearError { + #[error("failed to clear credentials: {0}")] + Io(#[from] std::io::Error), + + #[cfg(not(target_arch = "wasm32"))] + #[error("could not find a valid config location, please ensure $HOME is set")] + UnknownConfigLocation, + + #[cfg(target_arch = "wasm32")] + #[error("failed to get window.localStorage")] + NoLocalStorage, +} + #[cfg(not(target_arch = "wasm32"))] -pub use file::{load, store}; +pub use file::{clear, load, store}; #[cfg(target_arch = "wasm32")] -pub use web::{load, store}; +pub use web::{clear, load, store}; #[cfg(not(target_arch = "wasm32"))] mod file { - use super::{Credentials, LoadError, StoreError}; + use super::{ClearError, Credentials, LoadError, StoreError}; use std::path::PathBuf; fn credentials_path() -> Option { @@ -71,13 +85,24 @@ mod file { std::fs::write(path, data)?; Ok(()) } + + pub fn clear() -> Result<(), ClearError> { + let path = credentials_path().ok_or(ClearError::UnknownConfigLocation)?; + + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + // If the file didn't exist this isn't a failure. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(ClearError::Io(err)), + } + } } #[cfg(target_arch = "wasm32")] mod web { use wasm_bindgen::JsCast as _; - use super::{Credentials, LoadError, StoreError}; + use super::{ClearError, Credentials, LoadError, StoreError}; const STORAGE_KEY: &str = "rerun_auth"; @@ -95,6 +120,12 @@ mod web { } } + impl From for ClearError { + fn from(_: NoLocalStorage) -> Self { + Self::NoLocalStorage + } + } + #[expect(clippy::needless_pass_by_value)] pub fn string_from_js_value(s: wasm_bindgen::JsValue) -> String { // it's already a string @@ -140,4 +171,12 @@ mod web { .map_err(|err| std::io::Error::other(string_from_js_value(err)))?; Ok(()) } + + pub fn clear() -> Result<(), ClearError> { + let local_storage = get_local_storage()?; + local_storage + .remove_item(STORAGE_KEY) + .map_err(|err| std::io::Error::other(string_from_js_value(err)))?; + Ok(()) + } } diff --git a/crates/viewer/re_redap_browser/src/server_modal.rs b/crates/viewer/re_redap_browser/src/server_modal.rs index b3158524a393..2674b7367ecd 100644 --- a/crates/viewer/re_redap_browser/src/server_modal.rs +++ b/crates/viewer/re_redap_browser/src/server_modal.rs @@ -27,8 +27,8 @@ pub enum ServerModalMode { } /// Authentication state for the server modal. -struct Authentication { - email: Option, +pub(crate) struct Authentication { + pub(crate) email: Option, token: String, show_token_input: bool, login_flow: Option, @@ -93,7 +93,7 @@ pub struct ServerModal { mode: ServerModalMode, scheme: Scheme, host: String, - auth: Authentication, + pub(crate) auth: Authentication, port: u16, } @@ -104,7 +104,8 @@ impl Default for ServerModal { mode: ServerModalMode::Add, scheme: Scheme::Rerun, host: String::new(), - auth: Authentication::new(None, false), + // use_stored_credentials is true so we can read the email in the welcome screen + auth: Authentication::new(None, true), port: 443, } } @@ -150,6 +151,11 @@ impl ServerModal { self.modal.open(); } + pub fn logout(&mut self) { + self.auth.email = None; + self.auth.reset_login_flow(); + } + pub fn ui(&mut self, global_ctx: &GlobalContext<'_>, ctx: &Context<'_>, ui: &egui::Ui) { let was_open = self.modal.is_open(); diff --git a/crates/viewer/re_redap_browser/src/servers.rs b/crates/viewer/re_redap_browser/src/servers.rs index 91dfa880993a..bca261dd4872 100644 --- a/crates/viewer/re_redap_browser/src/servers.rs +++ b/crates/viewer/re_redap_browser/src/servers.rs @@ -354,6 +354,21 @@ impl RedapServers { self.servers.values() } + pub fn is_authenticated(&self, origin: &re_uri::Origin) -> bool { + self.servers + .get(origin) + .and_then(|server| server.connection_registry.credentials(origin)) + .is_some() + } + + pub fn logout(&mut self) { + self.server_modal_ui.logout(); + } + + pub fn auth_email(&self) -> Option { + self.server_modal_ui.auth.email.clone() + } + /// Per-frame housekeeping. /// /// - Process commands from the queue. @@ -452,6 +467,12 @@ impl RedapServers { self.command_sender.send(Command::OpenAddServerModal).ok(); } + pub fn open_edit_server_modal(&self, origin: re_uri::Origin) { + self.command_sender + .send(Command::OpenEditServerModal(origin)) + .ok(); + } + pub fn entry_ui( &self, viewer_ctx: &ViewerContext<'_>, diff --git a/crates/viewer/re_test_context/src/lib.rs b/crates/viewer/re_test_context/src/lib.rs index 642345524b27..a0b6b3ef6b81 100644 --- a/crates/viewer/re_test_context/src/lib.rs +++ b/crates/viewer/re_test_context/src/lib.rs @@ -749,10 +749,12 @@ impl TestContext { | SystemCommand::ClearActiveBlueprint | SystemCommand::ClearActiveBlueprintAndEnableHeuristics | SystemCommand::AddRedapServer { .. } + | SystemCommand::EditRedapServerModal { .. } | SystemCommand::UndoBlueprint { .. } | SystemCommand::RedoBlueprint { .. } | SystemCommand::CloseAllEntries | SystemCommand::SetAuthCredentials { .. } + | SystemCommand::Logout | SystemCommand::ShowNotification { .. } => handled = false, #[cfg(debug_assertions)] diff --git a/crates/viewer/re_ui/src/button.rs b/crates/viewer/re_ui/src/button.rs new file mode 100644 index 000000000000..4e9c163206f1 --- /dev/null +++ b/crates/viewer/re_ui/src/button.rs @@ -0,0 +1,141 @@ +use crate::{DesignTokens, UiExt as _}; +use egui::style::WidgetVisuals; +use egui::{Button, CornerRadius, IntoAtoms, Style}; + +pub enum Variant { + Primary, + Secondary, + Ghost, +} + +pub enum Size { + Normal, + Small, +} + +impl Size { + pub fn apply(&self, style: &mut Style) { + match self { + Self::Normal => { + style.spacing.button_padding = egui::vec2(12.0, 8.0); + all_visuals(style, |vis| { + vis.corner_radius = CornerRadius::same(6); + }); + } + Self::Small => { + style.spacing.button_padding = egui::vec2(8.0, 4.0); + all_visuals(style, |vis| { + vis.corner_radius = CornerRadius::same(3); + }); + } + } + } +} + +fn all_visuals(style: &mut Style, f: impl Fn(&mut WidgetVisuals)) { + f(&mut style.visuals.widgets.active); + f(&mut style.visuals.widgets.hovered); + f(&mut style.visuals.widgets.inactive); + f(&mut style.visuals.widgets.noninteractive); + f(&mut style.visuals.widgets.open); +} + +impl Variant { + pub fn apply(&self, style: &mut Style, tokens: &DesignTokens) { + match self { + Self::Primary => { + all_visuals(style, |vis| { + vis.bg_fill = tokens.bg_fill_inverse; + vis.weak_bg_fill = tokens.bg_fill_inverse; + vis.fg_stroke.color = tokens.text_inverse; + }); + style.visuals.widgets.hovered.bg_fill = tokens.bg_fill_inverse_hover; + style.visuals.widgets.hovered.weak_bg_fill = tokens.bg_fill_inverse_hover; + } + Self::Secondary => { + all_visuals(style, |vis| { + vis.bg_fill = tokens.widget_active_bg_fill; + vis.weak_bg_fill = tokens.widget_active_bg_fill; + }); + style.visuals.widgets.hovered.bg_fill = tokens.widget_noninteractive_bg_fill; + style.visuals.widgets.hovered.weak_bg_fill = tokens.widget_noninteractive_bg_fill; + } + Self::Ghost => { + // The default button + } + } + } +} + +pub struct ReButton<'a> { + pub variant: Variant, + pub size: Size, + pub inner: Button<'a>, +} + +impl<'a> ReButton<'a> { + pub fn new(atoms: impl IntoAtoms<'a>) -> Self { + Self::from_button(Button::new(atoms)) + } + + pub fn from_button(button: Button<'a>) -> Self { + ReButton { + inner: button, + size: Size::Normal, + variant: Variant::Ghost, + } + } + + pub fn primary(mut self) -> Self { + self.variant = Variant::Primary; + self + } + + pub fn secondary(mut self) -> Self { + self.variant = Variant::Secondary; + self + } + + pub fn ghost(mut self) -> Self { + self.variant = Variant::Ghost; + self + } + + pub fn small(mut self) -> Self { + self.size = Size::Small; + self + } + + pub fn normal(mut self) -> Self { + self.size = Size::Normal; + self + } +} + +pub trait ReButtonExt<'a> { + fn primary(self) -> ReButton<'a>; + fn secondary(self) -> ReButton<'a>; +} + +impl<'a> ReButtonExt<'a> for Button<'a> { + fn primary(self) -> ReButton<'a> { + ReButton::from_button(self).primary() + } + + fn secondary(self) -> ReButton<'a> { + ReButton::from_button(self).secondary() + } +} + +impl egui::Widget for ReButton<'_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let previous_style = ui.style().clone(); + let tokens = ui.tokens(); + let style = ui.style_mut(); + self.size.apply(style); + self.variant.apply(style, tokens); + let response = ui.add(self.inner); + ui.set_style(previous_style); + response + } +} diff --git a/crates/viewer/re_ui/src/egui_ext/card_layout.rs b/crates/viewer/re_ui/src/egui_ext/card_layout.rs new file mode 100644 index 000000000000..5bc0dabf41d1 --- /dev/null +++ b/crates/viewer/re_ui/src/egui_ext/card_layout.rs @@ -0,0 +1,80 @@ +use egui::{Frame, Id, NumExt as _, Ui}; + +pub struct CardLayoutItem { + pub frame: Frame, + pub min_width: f32, +} + +pub struct CardLayout { + items: Vec, +} + +#[derive(Default, Debug, Clone)] +struct IntroSectionLayoutStats { + max_inner_height: f32, +} + +impl CardLayout { + pub fn new(items: Vec) -> Self { + Self { items } + } + + pub fn show(self, ui: &mut Ui, mut show_item: impl FnMut(&mut Ui, usize)) { + let Self { mut items } = self; + // We pop from the end, so reverse to make it easier to read + items.reverse(); + + let available_width = ui.available_width(); + + let mut row = 0; + let mut index = 0; + + while !items.is_empty() { + let mut row_width = 0.0; + let mut row_items = vec![]; + while let Some(item) = items.pop_if(|item| { + row_width + item.min_width <= available_width || row_items.is_empty() + }) { + row_width += item.min_width; + row_items.push(item); + } + + let gap_space = ui.spacing().item_spacing.x * (row_items.len() - 1) as f32; + let gap_space_item = gap_space / row_items.len() as f32; + let item_growth = available_width / row_width; + + let row_stats_id = Id::new(row); + let row_stats = ui.data_mut(|data| { + data.get_temp_mut_or_default::(row_stats_id) + .clone() + }); + let mut new_row_stats = IntroSectionLayoutStats::default(); + + ui.horizontal(|ui| { + for item in row_items { + let frame = item.frame; + let frame_margin_x = frame.inner_margin.sum().x; + frame.show(ui, |ui| { + ui.set_width( + ((item_growth * item.min_width) - frame_margin_x - gap_space_item) + .at_most(ui.available_width()), + ); + show_item(&mut *ui, index); + + let height = ui.min_size().y; + new_row_stats.max_inner_height = + f32::max(new_row_stats.max_inner_height, height); + + ui.set_height(row_stats.max_inner_height); + }); + index += 1; + } + }); + + row += 1; + ui.data_mut(|data| { + data.insert_temp(row_stats_id, new_row_stats); + }); + } + } +} diff --git a/crates/viewer/re_ui/src/egui_ext/mod.rs b/crates/viewer/re_ui/src/egui_ext/mod.rs index a0a0678b3c68..01eafd5b8706 100644 --- a/crates/viewer/re_ui/src/egui_ext/mod.rs +++ b/crates/viewer/re_ui/src/egui_ext/mod.rs @@ -1,6 +1,7 @@ //! Things that should be upstream moved to egui/eframe at some point pub mod boxed_widget; +pub mod card_layout; pub mod context_ext; #[cfg(target_os = "macos")] mod mac_traffic_light_sizes; diff --git a/crates/viewer/re_ui/src/lib.rs b/crates/viewer/re_ui/src/lib.rs index fc1480a5e73e..9021241c68de 100644 --- a/crates/viewer/re_ui/src/lib.rs +++ b/crates/viewer/re_ui/src/lib.rs @@ -28,12 +28,14 @@ mod time_drag_value; mod ui_ext; mod ui_layout; +mod button; #[cfg(feature = "testing")] pub mod testing; use egui::NumExt as _; pub use self::{ + button::*, command::{UICommand, UICommandSender}, command_palette::{CommandPalette, CommandPaletteAction, CommandPaletteUrl}, context_ext::ContextExt, diff --git a/crates/viewer/re_ui/src/ui_ext.rs b/crates/viewer/re_ui/src/ui_ext.rs index 59d612379e80..3d6bc68b1de1 100644 --- a/crates/viewer/re_ui/src/ui_ext.rs +++ b/crates/viewer/re_ui/src/ui_ext.rs @@ -8,6 +8,7 @@ use egui::{ }; use crate::alert::Alert; +use crate::button::ReButton; use crate::{ ContextExt as _, DesignTokens, Icon, LabelStyle, icons, list_item::{self, LabelContent}, @@ -25,7 +26,11 @@ pub trait UiExt { fn ui_mut(&mut self) -> &mut egui::Ui; fn theme(&self) -> egui::Theme { - self.ui().ctx().theme() + if self.ui().visuals().dark_mode { + egui::Theme::Dark + } else { + egui::Theme::Light + } } fn tokens(&self) -> &'static DesignTokens { @@ -210,6 +215,14 @@ pub trait UiExt { response } + fn primary_button<'a>(&mut self, atoms: impl IntoAtoms<'a>) -> egui::Response { + self.ui_mut().add(ReButton::new(atoms).primary()) + } + + fn secondary_button<'a>(&mut self, atoms: impl IntoAtoms<'a>) -> egui::Response { + self.ui_mut().add(ReButton::new(atoms).secondary()) + } + fn re_checkbox<'a>( &mut self, checked: &'a mut bool, diff --git a/crates/viewer/re_viewer/Cargo.toml b/crates/viewer/re_viewer/Cargo.toml index 4433a4ae0f10..5f4b806c0e61 100644 --- a/crates/viewer/re_viewer/Cargo.toml +++ b/crates/viewer/re_viewer/Cargo.toml @@ -119,6 +119,7 @@ eframe = { workspace = true, default-features = false, features = [ "persistence", "wgpu", ] } +egui_extras.workspace = true egui_plot.workspace = true egui-wgpu.workspace = true egui.workspace = true diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index a14c6a636e7c..8fb8212647ce 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -1055,6 +1055,10 @@ impl App { self.command_sender.send_ui(UICommand::ExpandBlueprintPanel); } + SystemCommand::EditRedapServerModal(origin) => { + self.state.redap_servers.open_edit_server_modal(origin); + } + SystemCommand::LoadDataSource(data_source) => { self.load_data_source(store_hub, egui_ctx, &data_source); } @@ -1236,6 +1240,12 @@ impl App { re_log::error!("Failed to store credentials: {err}"); } } + SystemCommand::Logout => { + if let Err(err) = re_auth::oauth::clear_credentials() { + re_log::error!("Failed to logout: {err}"); + } + self.state.redap_servers.logout(); + } } } diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index ca5c5a96b880..e4c4f76bc6ff 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -23,6 +23,7 @@ use re_viewer_context::{ use re_viewport::ViewportUi; use re_viewport_blueprint::{ViewportBlueprint, ui::add_view_or_container_modal_ui}; +use crate::ui::{CloudState, LoginState}; use crate::{ StartupOptions, app_blueprint::AppBlueprint, app_blueprint_ctx::AppBlueprintCtx, history, navigation::Navigation, open_url_description::ViewerOpenUrlDescription, ui::settings_screen_ui, @@ -656,7 +657,35 @@ impl AppState { DisplayMode::RedapServer(origin) => { if origin == &*re_redap_browser::EXAMPLES_ORIGIN { - welcome_screen.ui(ui, welcome_screen_state, &rx_log.sources()); + let origin = redap_servers + .iter_servers() + .find(|s| !s.origin().is_localhost()) + .map(|s| s.origin()) + .cloned(); + + let email = redap_servers.auth_email(); + let origin_token = origin + .as_ref() + .map(|o| redap_servers.is_authenticated(o)) + .unwrap_or(false); + + let login_state = if origin_token { + LoginState::Auth { email } + } else { + LoginState::NoAuth + }; + + let login_state = CloudState { + login: login_state, + has_server: origin, + }; + welcome_screen.ui( + ui, + &ctx.global_context, + welcome_screen_state, + &rx_log.sources(), + &login_state, + ); } else { redap_servers.server_central_panel_ui(&ctx, ui, origin); } diff --git a/crates/viewer/re_viewer/src/ui/mod.rs b/crates/viewer/re_viewer/src/ui/mod.rs index 0c3cf3463b91..5615f8101a45 100644 --- a/crates/viewer/re_viewer/src/ui/mod.rs +++ b/crates/viewer/re_viewer/src/ui/mod.rs @@ -12,7 +12,11 @@ mod settings_screen; // ---- pub(crate) use { - self::mobile_warning_ui::mobile_warning_ui, self::top_panel::top_panel, - self::welcome_screen::WelcomeScreen, open_url_modal::OpenUrlModal, - settings_screen::settings_screen_ui, share_modal::ShareModal, + self::mobile_warning_ui::mobile_warning_ui, + self::top_panel::top_panel, + self::welcome_screen::WelcomeScreen, + self::welcome_screen::{CloudState, LoginState}, + open_url_modal::OpenUrlModal, + settings_screen::settings_screen_ui, + share_modal::ShareModal, }; diff --git a/crates/viewer/re_viewer/src/ui/welcome_screen/example_section.rs b/crates/viewer/re_viewer/src/ui/welcome_screen/example_section.rs index a0a1ffc6749a..dec4efbd5b48 100644 --- a/crates/viewer/re_viewer/src/ui/welcome_screen/example_section.rs +++ b/crates/viewer/re_viewer/src/ui/welcome_screen/example_section.rs @@ -3,7 +3,11 @@ use ehttp::{Request, fetch}; use itertools::Itertools as _; use poll_promise::Promise; +use crate::ui::CloudState; +use crate::ui::welcome_screen::intro_section::intro_section; +use crate::ui::welcome_screen::welcome_section::welcome_section_ui; use re_ui::{DesignTokens, UiExt as _}; +use re_viewer_context::GlobalContext; #[derive(Debug, serde::Deserialize)] struct ExampleThumbnail { @@ -32,7 +36,6 @@ struct ExampleDesc { // TODO(ab): use design tokens pub(super) const MIN_COLUMN_WIDTH: f32 = 250.0; const MAX_COLUMN_WIDTH: f32 = 337.0; -const MAX_COLUMN_COUNT: usize = 3; const COLUMN_HSPACE: f32 = 20.0; const AFTER_HEADER_VSPACE: f32 = 48.0; const TITLE_TO_GRID_VSPACE: f32 = 24.0; @@ -241,33 +244,37 @@ impl ExampleSection { /// │ │ │ /// │ │ │ /// ``` - pub(super) fn ui(&mut self, ui: &mut egui::Ui, header_ui: &impl Fn(&mut Ui)) { + pub(super) fn ui( + &mut self, + ui: &mut egui::Ui, + ctx: &GlobalContext<'_>, + login_state: &CloudState, + ) { let examples = self .examples .get_or_insert_with(|| load_manifest(ui.ctx(), self.manifest_url.clone())); + let max_width = ui.available_width().at_most(1048.0); + // vertical spacing isn't homogeneous so it's handled manually let grid_spacing = egui::vec2(COLUMN_HSPACE, 0.0); - let column_count = (((ui.available_width() + grid_spacing.x) - / (MIN_COLUMN_WIDTH + grid_spacing.x)) + let column_count = (((max_width + grid_spacing.x) / (MIN_COLUMN_WIDTH + grid_spacing.x)) .floor() as usize) - .clamp(1, MAX_COLUMN_COUNT); - let column_width = ((ui.available_width() + grid_spacing.x) / column_count as f32 - - grid_spacing.x) + .at_least(1); + let column_width = ((max_width + grid_spacing.x) / column_count as f32 - grid_spacing.x) .floor() .clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH); ui.horizontal(|ui| { // this space is added on the left so that the grid is centered - let centering_hspace = (ui.available_width() - - column_count as f32 * column_width - - (column_count - 1) as f32 * grid_spacing.x) - .max(0.0) - / 2.0; + let centering_hspace = (ui.available_width() - max_width).max(0.0) / 2.0; ui.add_space(centering_hspace); ui.vertical(|ui| { - header_ui(ui); + ui.set_max_width(max_width); + + welcome_section_ui(ui); + intro_section(ui, ctx, login_state); ui.add_space(AFTER_HEADER_VSPACE); diff --git a/crates/viewer/re_viewer/src/ui/welcome_screen/intro_section.rs b/crates/viewer/re_viewer/src/ui/welcome_screen/intro_section.rs new file mode 100644 index 000000000000..27e20ea5abc9 --- /dev/null +++ b/crates/viewer/re_viewer/src/ui/welcome_screen/intro_section.rs @@ -0,0 +1,165 @@ +use eframe::epaint::Margin; +use egui::{Button, Frame, RichText, TextStyle, Theme, Ui}; +use re_ui::egui_ext::card_layout::{CardLayout, CardLayoutItem}; +use re_ui::{ReButtonExt, UICommand, UICommandSender as _, UiExt as _, design_tokens_of}; +use re_uri::Origin; +use re_viewer_context::{GlobalContext, Item, SystemCommand, SystemCommandSender as _}; + +pub enum LoginState { + NoAuth, + Auth { email: Option }, +} + +pub struct CloudState { + pub has_server: Option, + pub login: LoginState, +} + +pub enum IntroItem<'a> { + DocItem { + title: &'static str, + url: &'static str, + body: &'static str, + }, + CloudLoginItem(&'a CloudState), +} + +impl<'a> IntroItem<'a> { + fn items(login_state: &'a CloudState) -> Vec { + vec![ + IntroItem::DocItem { + title: "Send data in", + url: "", + body: "Send data to Rerun from your running applications or existing files.", + }, + IntroItem::DocItem { + title: "Explore data", + url: "", + body: "Familiarize yourself with the basics of using the Rerun Viewer.", + }, + IntroItem::DocItem { + title: "Query data out", + url: "", + body: "Perform analysis and send back the results to the original recording.", + }, + IntroItem::CloudLoginItem(login_state), + ] + } + + fn frame(&self, ui: &Ui) -> Frame { + let opposite_theme = match ui.theme() { + Theme::Dark => Theme::Light, + Theme::Light => Theme::Dark, + }; + let opposite_tokens = design_tokens_of(opposite_theme); + let tokens = ui.tokens(); + let frame = Frame::new() + .inner_margin(Margin::same(16)) + .corner_radius(8) + .stroke(tokens.native_frame_stroke); + match self { + IntroItem::DocItem { .. } => frame, + IntroItem::CloudLoginItem(_) => frame.fill(opposite_tokens.panel_bg_color), + } + } + + fn card_item(&self, ui: &Ui) -> CardLayoutItem { + let frame = self.frame(ui); + let min_width = match &self { + IntroItem::DocItem { .. } => 200.0, + IntroItem::CloudLoginItem(_) => 400.0, + }; + CardLayoutItem { frame, min_width } + } + + fn show(&self, ui: &mut Ui, ctx: &GlobalContext<'_>) { + let label_size = 13.0; + ui.vertical(|ui| match self { + IntroItem::DocItem { title, url, body } => { + egui::Sides::new().shrink_left().show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + + ui.heading(RichText::new(*title).strong()); + }, |ui| { + ui.re_hyperlink("Docs", *url, true); + }); + ui.label(RichText::new(*body).size(label_size)); + } + IntroItem::CloudLoginItem(login_state) => { + let opposite_theme = match ui.theme() { + Theme::Dark => Theme::Light, + Theme::Light => Theme::Dark, + }; + ui.set_style(ui.ctx().style_of(opposite_theme)); + + ui.heading(RichText::new("Rerun Cloud").strong()); + + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.style_mut().text_styles.get_mut(&TextStyle::Body).expect("Should always have body text style").size = label_size; + ui.label( + "Iterate faster on robotics learning with unified infrastructure. Interested? Read more " + ); + ui.hyperlink_to("here", ""); + ui.label(" or "); + ui.hyperlink_to("book a demo", ""); + ui.label("."); + }); + + match login_state { + CloudState { has_server: None, login: LoginState::NoAuth } => { + if ui.primary_button("Add server and login").clicked() { + ctx.command_sender.send_ui(UICommand::AddRedapServer); + } + } + CloudState { has_server: None, login } => { + ui.horizontal_wrapped(|ui| { + if ui.primary_button("Add server").clicked() { + ctx.command_sender.send_ui(UICommand::AddRedapServer); + } + if let LoginState::Auth { email: Some(email) } = login { + ui.weak("logged in as"); + ui.strong(email); + } + }); + } + CloudState { has_server: Some(origin), login: LoginState::NoAuth } => { + ui.horizontal_wrapped(|ui| { + if ui.primary_button("Add credentials").clicked() { + ctx.command_sender.send_system(SystemCommand::EditRedapServerModal(origin.clone())); + } + ui.weak("for address"); + ui.strong(format!("{}", &origin.host)); + }); + } + CloudState { has_server: Some(origin), login: LoginState::Auth { .. } } => { + if ui.primary_button("Explore your data").clicked() { + ctx.command_sender.send_system(SystemCommand::set_selection(Item::RedapServer(origin.clone()))); + } + } + } + } + }); + } +} + +pub fn intro_section(ui: &mut egui::Ui, ctx: &GlobalContext<'_>, login_state: &CloudState) { + let items = IntroItem::items(login_state); + + ui.add_space(32.0); + + if let LoginState::Auth { email: Some(email) } = &login_state.login { + ui.strong(RichText::new(format!("Hi, {email}!")).size(15.0)); + + if ui.add(Button::new("Logout").secondary().small()).clicked() { + ctx.command_sender.send_system(SystemCommand::Logout); + } + + ui.add_space(32.0); + } + + CardLayout::new(items.iter().map(|item| item.card_item(ui)).collect()).show(ui, |ui, index| { + let item = &items[index]; + item.show(ui, ctx); + }); +} diff --git a/crates/viewer/re_viewer/src/ui/welcome_screen/mod.rs b/crates/viewer/re_viewer/src/ui/welcome_screen/mod.rs index 01197bfc751c..2475fc82e594 100644 --- a/crates/viewer/re_viewer/src/ui/welcome_screen/mod.rs +++ b/crates/viewer/re_viewer/src/ui/welcome_screen/mod.rs @@ -1,4 +1,5 @@ mod example_section; +mod intro_section; mod loading_data_ui; mod no_data_ui; mod welcome_section; @@ -7,10 +8,12 @@ use std::sync::Arc; use example_section::{ExampleSection, MIN_COLUMN_WIDTH}; use re_smart_channel::SmartChannelSource; -use welcome_section::welcome_section_ui; use crate::app_state::WelcomeScreenState; +pub use intro_section::{CloudState, LoginState}; +use re_viewer_context::GlobalContext; + #[derive(Default)] pub struct WelcomeScreen { example_page: ExampleSection, @@ -25,8 +28,10 @@ impl WelcomeScreen { pub fn ui( &mut self, ui: &mut egui::Ui, + ctx: &GlobalContext<'_>, welcome_screen_state: &WelcomeScreenState, log_sources: &[Arc], + login_state: &CloudState, ) { if welcome_screen_state.opacity <= 0.0 { return; @@ -61,7 +66,7 @@ impl WelcomeScreen { no_data_ui::no_data_ui(ui); } } else { - self.example_page.ui(ui, &welcome_section_ui); + self.example_page.ui(ui, ctx, login_state); } }); }); diff --git a/crates/viewer/re_viewer/src/ui/welcome_screen/welcome_section.rs b/crates/viewer/re_viewer/src/ui/welcome_screen/welcome_section.rs index fb46daa9d6bd..56e4c3ecbeb4 100644 --- a/crates/viewer/re_viewer/src/ui/welcome_screen/welcome_section.rs +++ b/crates/viewer/re_viewer/src/ui/welcome_screen/welcome_section.rs @@ -1,7 +1,7 @@ -use re_ui::{DesignTokens, UiExt as _}; +use re_ui::DesignTokens; pub(super) const DOCS_URL: &str = "https://www.rerun.io/docs"; -pub(super) const WELCOME_SCREEN_TITLE: &str = "Visualize multimodal data"; +pub(super) const WELCOME_SCREEN_TITLE: &str = "Welcome to Rerun"; pub(super) const WELCOME_SCREEN_BULLET_TEXT: &[&str] = &[ "Log data with the Rerun SDK in C++, Python, or Rust", "Visualize and explore live or recorded data", @@ -26,35 +26,5 @@ pub(super) fn welcome_section_ui(ui: &mut egui::Ui) { ) .wrap(), ); - - ui.add_space(18.0); - - let bullet_text = |ui: &mut egui::Ui, text: &str| { - ui.horizontal(|ui| { - ui.add_space(1.0); - ui.bullet(ui.visuals().strong_text_color()); - ui.add_space(5.0); - ui.add( - egui::Label::new( - egui::RichText::new(text) - .color(ui.visuals().widgets.active.text_color()) - .text_style(DesignTokens::welcome_screen_body()), - ) - .wrap(), - ); - }); - ui.add_space(4.0); - }; - - for text in WELCOME_SCREEN_BULLET_TEXT { - bullet_text(ui, text); - } - - ui.add_space(9.0); - - ui.scope(|ui| { - ui.style_mut().override_text_style = Some(DesignTokens::welcome_screen_body()); - ui.re_hyperlink("Go to documentation", DOCS_URL, true); - }); }); } diff --git a/crates/viewer/re_viewer_context/src/command_sender.rs b/crates/viewer/re_viewer_context/src/command_sender.rs index a6b577832609..a78cf8b307fc 100644 --- a/crates/viewer/re_viewer_context/src/command_sender.rs +++ b/crates/viewer/re_viewer_context/src/command_sender.rs @@ -29,6 +29,9 @@ pub enum SystemCommand { /// Add a new server to the redap browser. AddRedapServer(re_uri::Origin), + /// Open a modal to edit this redap server. + EditRedapServerModal(re_uri::Origin), + ChangeDisplayMode(crate::DisplayMode), /// Activates the setting display mode. @@ -144,6 +147,9 @@ pub enum SystemCommand { access_token: String, email: String, }, + + /// Logout from rerun cloud + Logout, } impl SystemCommand {