From 4717659423da3fdd585c22bcd2459276add99628 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 20 Mar 2023 17:40:02 +0100 Subject: [PATCH] Nicer toast notifications (#1621) * Replace egui-notify with an in-house library with line wraps * Click to close notification * Warn about installing additional loggers without setting up re_log * Test the new toast notification in re_ui_example * info -> debug level for GC events * misc cleanup * Only show toast notifications for log messages from rerun crates * Don't repaint too often * Fix crash on negative ttl * Use the correct dt * Fix clearing of toasts * Nicer formatting of connecting client ip --- Cargo.lock | 11 +- crates/re_arrow_store/src/store_gc.rs | 7 +- crates/re_log/src/channel_logger.rs | 7 ++ crates/re_log/src/multi_logger.rs | 9 ++ crates/re_sdk_comms/src/server.rs | 9 +- crates/re_ui/Cargo.toml | 1 + crates/re_ui/examples/re_ui_example.rs | 91 ++++++++++++--- crates/re_ui/src/lib.rs | 1 + crates/re_ui/src/toasts.rs | 154 +++++++++++++++++++++++++ crates/re_viewer/Cargo.toml | 1 - crates/re_viewer/src/app.rs | 46 ++++---- 11 files changed, 280 insertions(+), 57 deletions(-) create mode 100644 crates/re_ui/src/toasts.rs diff --git a/Cargo.lock b/Cargo.lock index fa24caab2ae1..af23fc79bcc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1301,15 +1301,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "egui-notify" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa87d2d91ebea916ae30837068f869f9b91242107f86f6abc3f085fbd160e195" -dependencies = [ - "egui", -] - [[package]] name = "egui-wgpu" version = "0.21.0" @@ -4077,6 +4068,7 @@ dependencies = [ "egui_extras", "image", "parking_lot 0.12.1", + "re_log", "serde", "serde_json", "strum 0.24.1", @@ -4097,7 +4089,6 @@ dependencies = [ "ctrlc", "eframe", "egui", - "egui-notify", "egui-wgpu", "egui_dock", "egui_extras", diff --git a/crates/re_arrow_store/src/store_gc.rs b/crates/re_arrow_store/src/store_gc.rs index 19d6020fe390..5eca6a872ce1 100644 --- a/crates/re_arrow_store/src/store_gc.rs +++ b/crates/re_arrow_store/src/store_gc.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use arrow2::array::{Array, ListArray}; -use re_log::{info, trace}; + +use re_log::trace; use re_log_types::{ComponentName, TimeRange, Timeline}; use crate::{ComponentBucket, DataStore}; @@ -59,7 +60,7 @@ impl DataStore { let drop_at_least_size_bytes = initial_size_bytes * p; let target_size_bytes = initial_size_bytes - drop_at_least_size_bytes; - info!( + re_log::debug!( kind = "gc", id = self.gc_id, %target, @@ -86,7 +87,7 @@ impl DataStore { let new_nb_rows = self.total_temporal_component_rows(); let new_size_bytes = self.total_temporal_component_size_bytes() as f64; - info!( + re_log::debug!( kind = "gc", id = self.gc_id, %target, diff --git a/crates/re_log/src/channel_logger.rs b/crates/re_log/src/channel_logger.rs index ec86f88e13da..e83c503af290 100644 --- a/crates/re_log/src/channel_logger.rs +++ b/crates/re_log/src/channel_logger.rs @@ -1,7 +1,13 @@ //! Capture log messages and send them to some receiver over a channel. pub struct LogMsg { + /// The verbosity level. pub level: log::Level, + + /// The module, starting with the crate name. + pub target: String, + + /// The contents of the log message. pub msg: String, } @@ -38,6 +44,7 @@ impl log::Log for ChannelLogger { .lock() .send(LogMsg { level: record.level(), + target: record.target().to_owned(), msg: record.args().to_string(), }) .ok(); diff --git a/crates/re_log/src/multi_logger.rs b/crates/re_log/src/multi_logger.rs index 88de5a73d342..d11ea5a0221b 100644 --- a/crates/re_log/src/multi_logger.rs +++ b/crates/re_log/src/multi_logger.rs @@ -1,9 +1,14 @@ //! Have multiple loggers implementing [`log::Log`] at once. +use std::sync::atomic::{AtomicBool, Ordering::SeqCst}; + static MULTI_LOGGER: MultiLogger = MultiLogger::new(); +static HAS_MULTI_LOGGER: AtomicBool = AtomicBool::new(false); + /// Install the multi-logger as the default logger. pub fn init() -> Result<(), log::SetLoggerError> { + HAS_MULTI_LOGGER.store(true, SeqCst); log::set_logger(&MULTI_LOGGER) } @@ -14,6 +19,10 @@ pub fn add_boxed_logger(logger: Box) { /// Install an additional global logger. pub fn add_logger(logger: &'static dyn log::Log) { + debug_assert!( + HAS_MULTI_LOGGER.load(SeqCst), + "You forgot to setup multi-logging" + ); MULTI_LOGGER.loggers.write().push(logger); } diff --git a/crates/re_sdk_comms/src/server.rs b/crates/re_sdk_comms/src/server.rs index 7fa717143213..b9d93a0b8030 100644 --- a/crates/re_sdk_comms/src/server.rs +++ b/crates/re_sdk_comms/src/server.rs @@ -38,7 +38,7 @@ pub fn serve(port: u16, options: ServerOptions) -> anyhow::Result, options: Server stream.peer_addr() )) .spawn(move || { + let addr_string = stream + .peer_addr() + .map_or_else(|_| "(unknown ip)".to_owned(), |addr| addr.to_string()); if options.quiet { - re_log::debug!("New SDK client connected: {:?}", stream.peer_addr()); + re_log::debug!("New SDK client connected: {addr_string}"); } else { - re_log::info!("New SDK client connected: {:?}", stream.peer_addr()); + re_log::info!("New SDK client connected: {addr_string}"); } if let Err(err) = run_client(stream, &tx, options) { diff --git a/crates/re_ui/Cargo.toml b/crates/re_ui/Cargo.toml index 2c2c55939745..cfa5732751ca 100644 --- a/crates/re_ui/Cargo.toml +++ b/crates/re_ui/Cargo.toml @@ -46,3 +46,4 @@ egui_dock = { workspace = true, optional = true, features = ["serde"] } [dev-dependencies] eframe = { workspace = true, default-features = false, features = ["wgpu"] } +re_log.workspace = true diff --git a/crates/re_ui/examples/re_ui_example.rs b/crates/re_ui/examples/re_ui_example.rs index 823197ce3a9f..40662ea06c58 100644 --- a/crates/re_ui/examples/re_ui_example.rs +++ b/crates/re_ui/examples/re_ui_example.rs @@ -1,6 +1,8 @@ -use re_ui::{Command, CommandPalette}; +use re_ui::{toasts, Command, CommandPalette}; fn main() -> eframe::Result<()> { + re_log::setup_native_logging(); + let native_options = eframe::NativeOptions { initial_window_size: Some([1200.0, 800.0].into()), follow_system_theme: false, @@ -17,34 +19,22 @@ fn main() -> eframe::Result<()> { ..Default::default() }; - let tree = egui_dock::Tree::new(vec![1, 2, 3]); - eframe::run_native( "re_ui example app", native_options, Box::new(move |cc| { let re_ui = re_ui::ReUi::load_and_apply(&cc.egui_ctx); - Box::new(ExampleApp { - re_ui, - - tree, - - left_panel: true, - right_panel: true, - bottom_panel: true, - - dummy_bool: true, - - cmd_palette: CommandPalette::default(), - pending_commands: Default::default(), - latest_cmd: Default::default(), - }) + Box::new(ExampleApp::new(re_ui)) }), ) } pub struct ExampleApp { re_ui: re_ui::ReUi, + toasts: toasts::Toasts, + + /// Listens to the local text log stream + text_log_rx: std::sync::mpsc::Receiver, tree: egui_dock::Tree, @@ -61,12 +51,67 @@ pub struct ExampleApp { latest_cmd: String, } +impl ExampleApp { + fn new(re_ui: re_ui::ReUi) -> Self { + let (logger, text_log_rx) = re_log::ChannelLogger::new(re_log::LevelFilter::Info); + re_log::add_boxed_logger(Box::new(logger)); + + let tree = egui_dock::Tree::new(vec![1, 2, 3]); + + Self { + re_ui, + toasts: Default::default(), + text_log_rx, + + tree, + + left_panel: true, + right_panel: true, + bottom_panel: true, + + dummy_bool: true, + + cmd_palette: CommandPalette::default(), + pending_commands: Default::default(), + latest_cmd: Default::default(), + } + } + + /// 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), + }); + } + } +} + impl eframe::App for ExampleApp { fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { [0.0; 4] // transparent so we can get rounded corners when doing [`re_ui::CUSTOM_WINDOW_DECORATIONS`] } fn update(&mut self, egui_ctx: &egui::Context, frame: &mut eframe::Frame) { + self.show_text_logs_as_notifications(); + self.toasts.show(egui_ctx); + egui::gui_zoom::zoom_with_keyboard_shortcuts( egui_ctx, frame.info().native_pixels_per_point, @@ -97,6 +142,16 @@ impl eframe::App for ExampleApp { 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 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. So much that we should ideally be auto-wrapping it at some point, much earlier than this."); + } }); egui::ScrollArea::both() diff --git a/crates/re_ui/src/lib.rs b/crates/re_ui/src/lib.rs index d2a7adf4d0cc..8a28c268afd4 100644 --- a/crates/re_ui/src/lib.rs +++ b/crates/re_ui/src/lib.rs @@ -5,6 +5,7 @@ mod command_palette; mod design_tokens; pub mod icons; mod static_image_cache; +pub mod toasts; mod toggle_switch; pub use command::Command; diff --git a/crates/re_ui/src/toasts.rs b/crates/re_ui/src/toasts.rs new file mode 100644 index 000000000000..f97123374347 --- /dev/null +++ b/crates/re_ui/src/toasts.rs @@ -0,0 +1,154 @@ +///! 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 WARNING_COLOR: Color32 = Color32::from_rgb(255, 212, 0); +pub const ERROR_COLOR: Color32 = Color32::from_rgb(255, 32, 0); +pub const SUCCESS_COLOR: Color32 = Color32::from_rgb(0, 255, 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 + .interact(egui::Sense::click()) + .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: &mut Toast) -> egui::Response { + egui::Frame::window(ui.style()) + .inner_margin(10.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.style_mut().wrap = Some(true); + 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 => ("⚠", WARNING_COLOR), + ToastKind::Error => ("❗", ERROR_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/re_viewer/Cargo.toml b/crates/re_viewer/Cargo.toml index 7bf553ac6676..5f147f4121f0 100644 --- a/crates/re_viewer/Cargo.toml +++ b/crates/re_viewer/Cargo.toml @@ -75,7 +75,6 @@ eframe = { workspace = true, default-features = false, features = [ egui = { workspace = true, features = ["extra_debug_asserts", "tracing"] } egui_dock = { workspace = true, features = ["serde"] } egui_extras = { workspace = true, features = ["tracing"] } -egui-notify = "0.6" egui-wgpu.workspace = true ehttp = "0.2" enumset.workspace = true diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index c5d12ada9cf4..362f0e1ce33f 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -2,7 +2,6 @@ use std::{any::Any, hash::Hash}; use ahash::HashMap; use egui::NumExt as _; -use egui_notify::Toasts; use instant::Instant; use itertools::Itertools as _; use nohash_hasher::IntMap; @@ -14,7 +13,7 @@ use re_format::format_number; use re_log_types::{ApplicationId, LogMsg, RecordingId}; use re_renderer::WgpuResourcePoolStatistics; use re_smart_channel::Receiver; -use re_ui::Command; +use re_ui::{toasts, Command}; use crate::{ app_icon::setup_app_icon, @@ -60,7 +59,7 @@ pub struct App { startup_options: StartupOptions, re_ui: re_ui::ReUi, - /// Listens to the local log stream + /// Listens to the local text log stream text_log_rx: std::sync::mpsc::Receiver, component_ui_registry: ComponentUiRegistry, @@ -80,8 +79,8 @@ pub struct App { /// Pending background tasks, using `poll_promise`. pending_promises: HashMap>>, - /// Toast notifications, using `egui-notify`. - toasts: Toasts, + /// Toast notifications. + toasts: toasts::Toasts, latest_memory_purge: instant::Instant, memory_panel: crate::memory_panel::MemoryPanel, @@ -151,7 +150,7 @@ impl App { #[cfg(not(target_arch = "wasm32"))] ctrl_c, pending_promises: Default::default(), - toasts: Toasts::new(), + toasts: toasts::Toasts::new(), latest_memory_purge: instant::Instant::now(), // TODO(emilk): `Instant::MIN` when we have our own `Instant` that supports it. memory_panel: Default::default(), memory_panel_open: false, @@ -657,23 +656,26 @@ impl App { fn show_text_logs_as_notifications(&mut self) { crate::profile_function!(); - let duration = Some(std::time::Duration::from_secs(4)); + 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; + } - while let Ok(re_log::LogMsg { level, msg }) = self.text_log_rx.try_recv() { - match level { - re_log::Level::Error => { - self.toasts.error(msg).set_duration(duration); - } - re_log::Level::Warn => { - self.toasts.warning(msg).set_duration(duration); - } - re_log::Level::Info => { - self.toasts.info(msg).set_duration(duration); - } + 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 => { - // too spammy + continue; // too spammy } - } + }; + + self.toasts.add(toasts::Toast { + kind, + text: msg, + options: toasts::ToastOptions::with_ttl_in_seconds(4.0), + }); } } @@ -783,7 +785,7 @@ impl App { { crate::profile_scope!("pruning"); if let Some(counted) = mem_use_before.counted { - re_log::info!( + re_log::debug!( "Attempting to purge {:.1}% of used RAM ({})…", 100.0 * fraction_to_purge, format_bytes(counted as f64 * fraction_to_purge as f64) @@ -802,7 +804,7 @@ impl App { if let (Some(counted_before), Some(counted_diff)) = (mem_use_before.counted, freed_memory.counted) { - re_log::info!( + re_log::debug!( "Freed up {} ({:.1}%)", format_bytes(counted_diff as _), 100.0 * counted_diff as f32 / counted_before as f32