diff --git a/Cargo.lock b/Cargo.lock index db5e8a231b96..b801adefce9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6543,6 +6543,7 @@ dependencies = [ "strum", "strum_macros", "sublime_fuzzy", + "time", ] [[package]] diff --git a/build/debug/arrow/src/arrow_cpp b/build/debug/arrow/src/arrow_cpp new file mode 160000 index 000000000000..a6eabc2b8900 --- /dev/null +++ b/build/debug/arrow/src/arrow_cpp @@ -0,0 +1 @@ +Subproject commit a6eabc2b890030578131aecc5e85900597d694a4 diff --git a/build/roundtrips/arrow/src/arrow_cpp b/build/roundtrips/arrow/src/arrow_cpp new file mode 160000 index 000000000000..a6eabc2b8900 --- /dev/null +++ b/build/roundtrips/arrow/src/arrow_cpp @@ -0,0 +1 @@ +Subproject commit a6eabc2b890030578131aecc5e85900597d694a4 diff --git a/crates/utils/re_log/src/channel_logger.rs b/crates/utils/re_log/src/channel_logger.rs index e83c503af290..0b2d153df40c 100644 --- a/crates/utils/re_log/src/channel_logger.rs +++ b/crates/utils/re_log/src/channel_logger.rs @@ -1,5 +1,6 @@ //! Capture log messages and send them to some receiver over a channel. +#[derive(Clone)] pub struct LogMsg { /// The verbosity level. pub level: log::Level, diff --git a/crates/viewer/re_ui/Cargo.toml b/crates/viewer/re_ui/Cargo.toml index ba0b0bc3d4a9..ef06151b62d6 100644 --- a/crates/viewer/re_ui/Cargo.toml +++ b/crates/viewer/re_ui/Cargo.toml @@ -50,6 +50,15 @@ smallvec.workspace = true strum_macros.workspace = true strum.workspace = true sublime_fuzzy.workspace = true +time = { workspace = true, features = ["formatting", "local-offset"] } + + +[target.'cfg(target_arch = "wasm32")'.dependencies] +time = { workspace = true, features = [ + "formatting", + "local-offset", + "wasm-bindgen", +] } [dev-dependencies] diff --git a/crates/viewer/re_ui/data/icons/notification.png b/crates/viewer/re_ui/data/icons/notification.png new file mode 100644 index 000000000000..b38ec1e1e180 Binary files /dev/null and b/crates/viewer/re_ui/data/icons/notification.png differ diff --git a/crates/viewer/re_ui/examples/re_ui_example/main.rs b/crates/viewer/re_ui/examples/re_ui_example/main.rs index 2ae12c4f9161..e06e466b11d1 100644 --- a/crates/viewer/re_ui/examples/re_ui_example/main.rs +++ b/crates/viewer/re_ui/examples/re_ui_example/main.rs @@ -2,8 +2,9 @@ mod drag_and_drop; mod hierarchical_drag_and_drop; mod right_panel; +use re_ui::notifications; use re_ui::{ - list_item, toasts, CommandPalette, ContextExt as _, DesignTokens, UICommand, UICommandSender, + list_item, CommandPalette, ContextExt as _, DesignTokens, UICommand, UICommandSender, UiExt as _, }; @@ -64,7 +65,7 @@ fn main() -> eframe::Result { } pub struct ExampleApp { - toasts: toasts::Toasts, + notifications: notifications::NotificationUi, /// Listens to the local text log stream text_log_rx: std::sync::mpsc::Receiver, @@ -103,7 +104,7 @@ impl ExampleApp { let (command_sender, command_receiver) = command_channel(); Self { - toasts: Default::default(), + notifications: Default::default(), text_log_rx, tree, @@ -127,26 +128,8 @@ impl ExampleApp { /// Show recent text log messages to the user as toast notifications. fn show_text_logs_as_notifications(&mut self) { - while let Ok(re_log::LogMsg { - level, - target: _, - msg, - }) = self.text_log_rx.try_recv() - { - let kind = match level { - re_log::Level::Error => toasts::ToastKind::Error, - re_log::Level::Warn => toasts::ToastKind::Warning, - re_log::Level::Info => toasts::ToastKind::Info, - re_log::Level::Debug | re_log::Level::Trace => { - continue; // too spammy - } - }; - - self.toasts.add(toasts::Toast { - kind, - text: msg, - options: toasts::ToastOptions::with_ttl_in_seconds(4.0), - }); + while let Ok(message) = self.text_log_rx.try_recv() { + self.notifications.add_log(message); } } } @@ -158,7 +141,6 @@ impl eframe::App for ExampleApp { fn update(&mut self, egui_ctx: &egui::Context, _frame: &mut eframe::Frame) { self.show_text_logs_as_notifications(); - self.toasts.show(egui_ctx); self.top_bar(egui_ctx); @@ -174,29 +156,29 @@ impl eframe::App for ExampleApp { let left_panel_top_section_ui = |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { ui.strong("Left bar"); - }); - if ui.button("Log info").clicked() { - re_log::info!( - "A lot of text on info level.\nA lot of text in fact. So \ + if ui.button("Log info").clicked() { + re_log::info!( + "A lot of text on info level.\nA lot of text in fact. So \ much that we should ideally be auto-wrapping it at some point, much \ earlier than this." - ); - } - if ui.button("Log warn").clicked() { - re_log::warn!( - "A lot of text on warn level.\nA lot of text in fact. So \ - much that we should ideally be auto-wrapping it at some point, much \ - earlier than this." - ); - } - if ui.button("Log error").clicked() { - re_log::error!( - "A lot of text on error level.\nA lot of text in fact. \ + ); + } + if ui.button("Log warn").clicked() { + re_log::warn!( + "A lot of text on warn level.\nA lot of text in fact." + ); + } + if ui.button("Log error").clicked() { + re_log::error!( + "A lot of text on error level.\nA lot of text in fact. \ So much that we should ideally be auto-wrapping it at some point, much \ - earlier than this." - ); - } + earlier than this. Lorem ipsum sit dolor amet. Lorem ipsum sit dolor amet. \ + Lorem ipsum sit dolor amet. Lorem ipsum sit dolor amet. Lorem ipsum sit dolor amet. \ + Lorem ipsum sit dolor amet." + ); + } + }); }; // bottom section closure @@ -431,6 +413,8 @@ impl ExampleApp { &re_ui::icons::LEFT_PANEL_TOGGLE, &mut self.show_left_panel, ); + + notifications::notification_toggle_button(ui, &mut self.notifications); }); } } diff --git a/crates/viewer/re_ui/src/context_ext.rs b/crates/viewer/re_ui/src/context_ext.rs index 7d83f269cbdd..fdd8308d4400 100644 --- a/crates/viewer/re_ui/src/context_ext.rs +++ b/crates/viewer/re_ui/src/context_ext.rs @@ -1,6 +1,6 @@ use egui::{emath::Float, pos2, Align2, Color32, Mesh, Rect, Shape, Vec2}; -use crate::toasts::SUCCESS_COLOR; +use crate::SUCCESS_COLOR; use crate::{DesignTokens, TopBarStyle}; /// Extension trait for [`egui::Context`]. diff --git a/crates/viewer/re_ui/src/design_tokens.rs b/crates/viewer/re_ui/src/design_tokens.rs index aa740ce30da4..32cda5097531 100644 --- a/crates/viewer/re_ui/src/design_tokens.rs +++ b/crates/viewer/re_ui/src/design_tokens.rs @@ -441,6 +441,10 @@ impl DesignTokens { pub fn drop_target_container_stroke(&self) -> egui::Stroke { egui::Stroke::new(2.0, self.color(ColorToken::blue(S350))) } + + pub fn text(&self, text: impl Into, token: ColorToken) -> egui::RichText { + egui::RichText::new(text).color(self.color(token)) + } } // ---------------------------------------------------------------------------- diff --git a/crates/viewer/re_ui/src/icons.rs b/crates/viewer/re_ui/src/icons.rs index 6d6aed335a86..11c2d8c3ff42 100644 --- a/crates/viewer/re_ui/src/icons.rs +++ b/crates/viewer/re_ui/src/icons.rs @@ -59,6 +59,7 @@ pub const ARROW_RIGHT: Icon = icon_from_path!("../data/icons/arrow_right.png"); pub const ARROW_DOWN: Icon = icon_from_path!("../data/icons/arrow_down.png"); pub const LOOP: Icon = icon_from_path!("../data/icons/loop.png"); +pub const NOTIFICATION: Icon = icon_from_path!("../data/icons/notification.png"); pub const RIGHT_PANEL_TOGGLE: Icon = icon_from_path!("../data/icons/right_panel_toggle.png"); pub const BOTTOM_PANEL_TOGGLE: Icon = icon_from_path!("../data/icons/bottom_panel_toggle.png"); pub const LEFT_PANEL_TOGGLE: Icon = icon_from_path!("../data/icons/left_panel_toggle.png"); diff --git a/crates/viewer/re_ui/src/lib.rs b/crates/viewer/re_ui/src/lib.rs index ba9b22dbfa33..bae5f64d2117 100644 --- a/crates/viewer/re_ui/src/lib.rs +++ b/crates/viewer/re_ui/src/lib.rs @@ -10,12 +10,13 @@ pub mod icons; pub mod list_item; mod markdown_utils; pub mod modal; +pub mod notifications; mod section_collapsing_header; pub mod syntax_highlighting; -pub mod toasts; mod ui_ext; pub mod zoom_pan_area; +use egui::Color32; use egui::NumExt as _; pub use self::{ @@ -46,6 +47,9 @@ pub const CUSTOM_WINDOW_DECORATIONS: bool = false; // !FULLSIZE_CONTENT; // TODO /// close/maximize/minimize buttons and app title. pub const NATIVE_WINDOW_BAR: bool = !FULLSIZE_CONTENT && !CUSTOM_WINDOW_DECORATIONS; +pub const INFO_COLOR: Color32 = Color32::from_rgb(0, 155, 255); +pub const SUCCESS_COLOR: Color32 = Color32::from_rgb(0, 240, 32); + // ---------------------------------------------------------------------------- pub struct TopBarStyle { diff --git a/crates/viewer/re_ui/src/notifications.rs b/crates/viewer/re_ui/src/notifications.rs new file mode 100644 index 000000000000..669cb3ab0995 --- /dev/null +++ b/crates/viewer/re_ui/src/notifications.rs @@ -0,0 +1,460 @@ +use std::time::Duration; + +use egui::NumExt as _; +pub use re_log::Level; +use time::OffsetDateTime; + +use crate::design_tokens; +use crate::icons; +use crate::ColorToken; +use crate::Scale; +use crate::UiExt; + +fn now() -> OffsetDateTime { + OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum NotificationLevel { + Info = 0, + Success = 1, + Warning = 2, + Error = 3, +} + +impl NotificationLevel { + fn color(&self, ui: &egui::Ui) -> egui::Color32 { + match self { + Self::Info => crate::INFO_COLOR, + Self::Warning => ui.style().visuals.warn_fg_color, + Self::Error => ui.style().visuals.error_fg_color, + Self::Success => crate::SUCCESS_COLOR, + } + } +} + +impl From for NotificationLevel { + fn from(value: re_log::Level) -> Self { + match value { + re_log::Level::Trace | re_log::Level::Debug | re_log::Level::Info => Self::Info, + re_log::Level::Warn => Self::Warning, + re_log::Level::Error => Self::Error, + } + } +} + +fn is_relevant(target: &str, level: re_log::Level) -> bool { + let is_rerun_crate = target.starts_with("rerun") || target.starts_with("re_"); + if !is_rerun_crate { + return false; + } + + matches!( + level, + re_log::Level::Warn | re_log::Level::Error | re_log::Level::Info + ) +} + +fn notification_panel_popup_id() -> egui::Id { + egui::Id::new("notification_panel_popup") +} + +pub fn notification_toggle_button(ui: &mut egui::Ui, notification_ui: &mut NotificationUi) { + let popup_id = notification_panel_popup_id(); + + let is_panel_visible = ui.memory(|mem| mem.is_popup_open(popup_id)); + let button_response = + ui.medium_icon_toggle_button(&icons::NOTIFICATION, &mut is_panel_visible.clone()); + + if button_response.clicked() { + ui.memory_mut(|mem| mem.toggle_popup(popup_id)); + } + + if let Some(level) = notification_ui.unread_notification_level { + let pos = button_response.rect.right_top() + egui::vec2(-2.0, 2.0); + let radius = 3.0; + let color = level.color(ui); + ui.painter().circle_filled(pos, radius, color); + } + + notification_ui.ui(ui.ctx(), &button_response); +} + +struct Notification { + level: NotificationLevel, + text: String, + + /// When this notification was added to the list. + created_at: OffsetDateTime, + + /// Time to live for toasts, the notification itself lives until dismissed. + toast_ttl: Duration, + + /// Whether this notification has been read. + is_unread: bool, +} + +pub struct NotificationUi { + /// State of every notification. + /// + /// Notifications are stored in order of ascending `created_at`, so the latest one is at the end. + notifications: Vec, + + unread_notification_level: Option, + was_open_last_frame: bool, + + /// Panel that shows all notifications. + panel: NotificationPanel, + + /// Toasts that show up for a short time. + toasts: Toasts, +} + +impl Default for NotificationUi { + fn default() -> Self { + Self::new() + } +} + +impl NotificationUi { + pub fn new() -> Self { + Self { + notifications: Vec::new(), + unread_notification_level: None, + was_open_last_frame: false, + panel: NotificationPanel::new(), + toasts: Toasts::new(), + } + } + + pub fn unread_notification_level(&self) -> Option { + self.unread_notification_level + } + + pub fn add_log(&mut self, message: re_log::LogMsg) { + if !is_relevant(&message.target, message.level) { + return; + } + + self.push(message.level.into(), message.msg); + } + + pub fn success(&mut self, text: impl Into) { + self.push(NotificationLevel::Success, text.into()); + } + + fn push(&mut self, level: NotificationLevel, text: String) { + self.notifications.push(Notification { + level, + text, + + created_at: now(), + toast_ttl: base_ttl(), + is_unread: true, + }); + + if Some(level) > self.unread_notification_level { + self.unread_notification_level = Some(level); + } + } + + fn ui(&mut self, egui_ctx: &egui::Context, button_response: &egui::Response) { + let is_panel_visible = + egui_ctx.memory(|mem| mem.is_popup_open(notification_panel_popup_id())); + if is_panel_visible { + // Dismiss all toasts when opening panel + self.unread_notification_level = None; + for notification in &mut self.notifications { + notification.toast_ttl = Duration::ZERO; + } + } + if !is_panel_visible && self.was_open_last_frame { + // Mark all as read after closing panel + for notification in &mut self.notifications { + notification.is_unread = false; + } + } + self.was_open_last_frame = is_panel_visible; + + if is_panel_visible { + let panel_response = self.panel.show(egui_ctx, &mut self.notifications); + let escape_pressed = + egui_ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if escape_pressed + || button_response.clicked_elsewhere() && panel_response.clicked_elsewhere() + { + egui_ctx.memory_mut(|mem| mem.close_popup()); + } + } + + self.toasts.show(egui_ctx, &mut self.notifications[..]); + } +} + +struct NotificationPanel { + id: egui::Id, +} + +impl NotificationPanel { + fn new() -> Self { + Self { + id: egui::Id::new("__notifications"), + } + } + + fn show( + &self, + egui_ctx: &egui::Context, + notifications: &mut Vec, + ) -> egui::Response { + let panel_width = 356.0; + let panel_max_height = (egui_ctx.screen_rect().height() - 100.0) + .at_least(0.0) + .at_most(640.0); + + let mut to_dismiss = None; + + let notification_list = |ui: &mut egui::Ui| { + if notifications.is_empty() { + ui.label( + design_tokens() + .text("No notifications yet.", ColorToken::gray(Scale::S450)) + .weak(), + ); + + return; + } + + for (i, notification) in notifications.iter().enumerate().rev() { + show_notification(ui, notification, DisplayMode::Panel, || { + to_dismiss = Some(i); + }); + } + }; + + let mut dismiss_all = false; + + let response = egui::Area::new(self.id) + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-8.0, 32.0)) + .order(egui::Order::Foreground) + .interactable(true) + .movable(false) + .show(egui_ctx, |ui| { + egui::Frame::window(ui.style()) + .fill(design_tokens().color(ColorToken::gray(Scale::S150))) + .rounding(8.0) + .inner_margin(8.0) + .show(ui, |ui| { + ui.set_width(panel_width); + ui.set_max_height(panel_max_height); + + ui.horizontal_top(|ui| { + if !notifications.is_empty() { + ui.label(format!("Notifications ({})", notifications.len())); + } else { + ui.label("Notifications"); + } + ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { + if ui.small_icon_button(&icons::CLOSE).clicked() { + ui.memory_mut(|mem| mem.close_popup()); + } + }); + }); + egui::ScrollArea::vertical() + .min_scrolled_height(panel_max_height / 2.0) + .max_height(panel_max_height) + .show(ui, notification_list); + + if !notifications.is_empty() { + ui.horizontal_top(|ui| { + if ui.button("Dismiss all").clicked() { + dismiss_all = true; + }; + }); + } + }); + }) + .response; + + if dismiss_all { + notifications.clear(); + } else if let Some(to_dismiss) = to_dismiss { + notifications.remove(to_dismiss); + } + + response + } +} + +fn base_ttl() -> Duration { + Duration::from_secs_f64(4.0) +} + +struct Toasts { + id: egui::Id, +} + +impl Default for Toasts { + fn default() -> Self { + Self::new() + } +} + +impl Toasts { + fn new() -> Self { + Self { + id: egui::Id::new("__toasts"), + } + } + + /// Shows and updates all toasts + fn show(&self, egui_ctx: &egui::Context, notifications: &mut [Notification]) { + let dt = Duration::from_secs_f32(egui_ctx.input(|i| i.unstable_dt)); + let mut offset = egui::vec2(-8.0, 32.0); + + let mut first_nonzero_ttl = None; + + for (i, notification) in notifications + .iter_mut() + .enumerate() + .filter(|(_, n)| n.toast_ttl > Duration::ZERO) + { + first_nonzero_ttl.get_or_insert(notification.toast_ttl); + + let response = egui::Area::new(self.id.with(i)) + .anchor(egui::Align2::RIGHT_TOP, offset) + .order(egui::Order::Foreground) + .interactable(true) + .movable(false) + .show(egui_ctx, |ui| { + show_notification(ui, notification, DisplayMode::Toast, || {}) + }) + .response; + + if !response.hovered() { + if notification.toast_ttl < dt { + notification.toast_ttl = Duration::ZERO; + } else { + notification.toast_ttl -= dt; + } + } + + let response = response.on_hover_text("Click to close and copy contents"); + + if response.clicked() { + egui_ctx.output_mut(|o| o.copied_text = notification.text.clone()); + notification.toast_ttl = Duration::ZERO; + } + + offset.y += response.rect.height() + 8.0; + } + + if let Some(first_nonzero_ttl) = first_nonzero_ttl { + egui_ctx.request_repaint_after(first_nonzero_ttl); + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DisplayMode { + Panel, + Toast, +} + +fn show_notification( + ui: &mut egui::Ui, + notification: &Notification, + mode: DisplayMode, + mut on_dismiss: impl FnMut(), +) -> egui::Response { + let background_color = if mode == DisplayMode::Toast || notification.is_unread { + design_tokens().color(ColorToken::gray(Scale::S200)) + } else { + design_tokens().color(ColorToken::gray(Scale::S150)) + }; + + egui::Frame::window(ui.style()) + .rounding(4.0) + .inner_margin(10.0) + .fill(background_color) + .shadow(egui::Shadow::NONE) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.horizontal_top(|ui| { + log_level_icon(ui, notification.level); + ui.horizontal_top(|ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + ui.set_width(270.0); + ui.label( + design_tokens() + .text(notification.text.clone(), ColorToken::gray(Scale::S775)) + .weak(), + ); + }); + + ui.add_space(4.0); + if mode == DisplayMode::Panel { + notification_age_label(ui, notification); + } + }); + + ui.horizontal_top(|ui| { + if mode != DisplayMode::Panel { + return; + } + + ui.add_space(17.0); + if ui.button("Dismiss").clicked() { + on_dismiss(); + } + }); + }) + }) + .response +} + +fn notification_age_label(ui: &mut egui::Ui, notification: &Notification) { + let age = (now() - notification.created_at).as_seconds_f64(); + + let formatted = if age < 10.0 { + ui.ctx().request_repaint_after(Duration::from_secs(1)); + + "now".to_owned() + } else if age < 60.0 { + ui.ctx().request_repaint_after(Duration::from_secs(1)); + + format!("{age:.0}s") + } else { + ui.ctx().request_repaint_after(Duration::from_secs(60)); + + notification + .created_at + .format(&time::macros::format_description!("[hour]:[minute]")) + .unwrap_or_default() + }; + + ui.horizontal_top(|ui| { + ui.set_min_width(30.0); + ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { + ui.label( + design_tokens() + .text(formatted, ColorToken::gray(Scale::S450)) + .weak(), + ) + .on_hover_text(format!("{}", notification.created_at)); + }); + }); +} + +fn log_level_icon(ui: &mut egui::Ui, level: NotificationLevel) { + let color = match level { + NotificationLevel::Info => crate::INFO_COLOR, + NotificationLevel::Warning => ui.style().visuals.warn_fg_color, + NotificationLevel::Error => ui.style().visuals.error_fg_color, + NotificationLevel::Success => crate::SUCCESS_COLOR, + }; + + let (rect, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter() + .circle_filled(rect.center() + egui::vec2(0.0, 2.0), 5.0, color); +} diff --git a/crates/viewer/re_ui/src/toasts.rs b/crates/viewer/re_ui/src/toasts.rs deleted file mode 100644 index 82773d03a38b..000000000000 --- a/crates/viewer/re_ui/src/toasts.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! A toast notification system for egui, roughly based on . - -use std::collections::HashMap; - -use egui::Color32; - -pub const INFO_COLOR: Color32 = Color32::from_rgb(0, 155, 255); -pub const SUCCESS_COLOR: Color32 = Color32::from_rgb(0, 240, 32); - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum ToastKind { - Info, - Warning, - Error, - Success, - Custom(u32), -} - -#[derive(Clone)] -pub struct Toast { - pub kind: ToastKind, - pub text: String, - pub options: ToastOptions, -} - -#[derive(Copy, Clone)] -pub struct ToastOptions { - /// This can be used to show or hide the toast type icon. - pub show_icon: bool, - - /// Time to live in seconds. - pub ttl_sec: f64, -} - -impl ToastOptions { - pub fn with_ttl_in_seconds(ttl_sec: f64) -> Self { - Self { - show_icon: true, - ttl_sec, - } - } -} - -impl Toast { - pub fn close(&mut self) { - self.options.ttl_sec = 0.0; - } -} - -pub type ToastContents = dyn Fn(&mut egui::Ui, &mut Toast) -> egui::Response; - -pub struct Toasts { - id: egui::Id, - custom_toast_contents: HashMap>, - toasts: Vec, -} - -impl Default for Toasts { - fn default() -> Self { - Self::new() - } -} - -impl Toasts { - pub fn new() -> Self { - Self { - id: egui::Id::new("__toasts"), - custom_toast_contents: Default::default(), - toasts: Vec::new(), - } - } - - /// Adds a new toast - pub fn add(&mut self, toast: Toast) -> &mut Self { - self.toasts.push(toast); - self - } - - /// Shows and updates all toasts - pub fn show(&mut self, egui_ctx: &egui::Context) { - let Self { - id, - custom_toast_contents, - toasts, - } = self; - - let dt = egui_ctx.input(|i| i.unstable_dt) as f64; - - toasts.retain(|toast| 0.0 < toast.options.ttl_sec); - - let mut offset = egui::vec2(-8.0, 8.0); - - for (i, toast) in toasts.iter_mut().enumerate() { - let response = egui::Area::new(id.with(i)) - .anchor(egui::Align2::RIGHT_TOP, offset) - .order(egui::Order::Foreground) - .interactable(true) - .movable(false) - .show(egui_ctx, |ui| { - if let Some(add_contents) = custom_toast_contents.get_mut(&toast.kind) { - add_contents(ui, toast); - } else { - default_toast_contents(ui, toast); - }; - }) - .response; - - let response = response.on_hover_text("Click to close and copy contents"); - - if !response.hovered() { - toast.options.ttl_sec -= dt; - if toast.options.ttl_sec.is_finite() { - egui_ctx.request_repaint_after(std::time::Duration::from_secs_f64( - toast.options.ttl_sec.max(0.0), - )); - } - } - - if response.clicked() { - egui_ctx.output_mut(|o| o.copied_text = toast.text.clone()); - toast.close(); - } - - offset.y += response.rect.height() + 8.0; - } - } -} - -fn default_toast_contents(ui: &mut egui::Ui, toast: &Toast) -> egui::Response { - egui::Frame::window(ui.style()) - .inner_margin(10.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); - ui.set_max_width(400.0); - ui.spacing_mut().item_spacing = egui::Vec2::splat(5.0); - - if toast.options.show_icon { - let (icon, icon_color) = match toast.kind { - ToastKind::Warning => ("⚠", ui.style().visuals.warn_fg_color), - ToastKind::Error => ("❗", ui.style().visuals.error_fg_color), - ToastKind::Success => ("✔", SUCCESS_COLOR), - _ => ("ℹ", INFO_COLOR), - }; - ui.label(egui::RichText::new(icon).color(icon_color)); - } - ui.label(toast.text.clone()); - }) - }) - .response -} diff --git a/crates/viewer/re_ui/src/ui_ext.rs b/crates/viewer/re_ui/src/ui_ext.rs index 561f772b2ab0..706f9311c6aa 100644 --- a/crates/viewer/re_ui/src/ui_ext.rs +++ b/crates/viewer/re_ui/src/ui_ext.rs @@ -7,8 +7,7 @@ use egui::{ use crate::{ design_tokens, icons, list_item::{self, LabelContent, ListItem}, - toasts::SUCCESS_COLOR, - DesignTokens, Icon, LabelStyle, + DesignTokens, Icon, LabelStyle, SUCCESS_COLOR, }; static FULL_SPAN_TAG: &str = "rerun_full_span"; diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index 6e2a2e441079..bc5da3467cfb 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -9,7 +9,7 @@ use re_entity_db::entity_db::EntityDb; use re_log_types::{ApplicationId, FileSource, LogMsg, StoreKind}; use re_renderer::WgpuResourcePoolStatistics; use re_smart_channel::{ReceiveSet, SmartChannelSource}; -use re_ui::{toasts, DesignTokens, UICommand, UICommandSender}; +use re_ui::{notifications, DesignTokens, UICommand, UICommandSender}; use re_viewer_context::{ command_channel, store_hub::{BlueprintPersistence, StoreHub, StoreHubStats}, @@ -193,8 +193,8 @@ pub struct App { /// Interface for all recordings and blueprints pub(crate) store_hub: Option, - /// Toast notifications. - toasts: toasts::Toasts, + /// Notification panel. + pub(crate) notifications: notifications::NotificationUi, memory_panel: crate::memory_panel::MemoryPanel, memory_panel_open: bool, @@ -333,7 +333,8 @@ impl App { blueprint_loader(), &crate::app_blueprint::setup_welcome_screen_blueprint, )), - toasts: toasts::Toasts::new(), + notifications: notifications::NotificationUi::new(), + memory_panel: Default::default(), memory_panel_open: false, @@ -975,11 +976,8 @@ impl App { self.egui_ctx .output_mut(|o| o.copied_text = direct_link.clone()); - self.toasts.add(toasts::Toast { - kind: toasts::ToastKind::Success, - text: format!("Copied {direct_link:?} to clipboard"), - options: toasts::ToastOptions::with_ttl_in_seconds(4.0), - }); + self.notifications + .success(format!("Copied {direct_link:?} to clipboard")); } fn memory_panel_ui( @@ -1117,6 +1115,8 @@ impl App { render_ctx.before_submit(); } } + + self.show_text_logs_as_notifications(); }); } @@ -1124,26 +1124,8 @@ impl App { fn show_text_logs_as_notifications(&mut self) { re_tracing::profile_function!(); - while let Ok(re_log::LogMsg { level, target, msg }) = self.text_log_rx.try_recv() { - let is_rerun_crate = target.starts_with("rerun") || target.starts_with("re_"); - if !is_rerun_crate { - continue; - } - - let kind = match level { - re_log::Level::Error => toasts::ToastKind::Error, - re_log::Level::Warn => toasts::ToastKind::Warning, - re_log::Level::Info => toasts::ToastKind::Info, - re_log::Level::Debug | re_log::Level::Trace => { - continue; // too spammy - } - }; - - self.toasts.add(toasts::Toast { - kind, - text: msg, - options: toasts::ToastOptions::with_ttl_in_seconds(4.0), - }); + while let Ok(message) = self.text_log_rx.try_recv() { + self.notifications.add_log(message); } } @@ -1874,7 +1856,6 @@ impl eframe::App for App { store_hub.begin_frame(renderer_active_frame_idx); } - self.show_text_logs_as_notifications(); self.receive_messages(&mut store_hub, egui_ctx); if self.app_options().blueprint_gc { @@ -1935,10 +1916,6 @@ impl eframe::App for App { paint_native_window_frame(egui_ctx); } - if !self.screenshotter.is_screenshotting() { - self.toasts.show(egui_ctx); - } - if let Some(cmd) = self.cmd_palette.show(egui_ctx) { self.command_sender.send_ui(cmd); } diff --git a/crates/viewer/re_viewer/src/ui/top_panel.rs b/crates/viewer/re_viewer/src/ui/top_panel.rs index d3a76051cfae..74f63d57ffc3 100644 --- a/crates/viewer/re_viewer/src/ui/top_panel.rs +++ b/crates/viewer/re_viewer/src/ui/top_panel.rs @@ -246,7 +246,7 @@ fn connection_status_ui(ui: &mut egui::Ui, rx: &ReceiveSet } /// Lay out the panel button right-to-left -fn panel_buttons_r2l(app: &App, app_blueprint: &AppBlueprint<'_>, ui: &mut egui::Ui) { +fn panel_buttons_r2l(app: &mut App, app_blueprint: &AppBlueprint<'_>, ui: &mut egui::Ui) { #[cfg(target_arch = "wasm32")] if app.is_fullscreen_allowed() { let icon = if app.is_fullscreen_mode() { @@ -311,6 +311,8 @@ fn panel_buttons_r2l(app: &App, app_blueprint: &AppBlueprint<'_>, ui: &mut egui: { app_blueprint.toggle_blueprint_panel(&app.command_sender); } + + re_ui::notifications::notification_toggle_button(ui, &mut app.notifications); } /// Shows clickable website link as an image (text doesn't look as nice)