diff --git a/crates/viewer/re_data_ui/src/component.rs b/crates/viewer/re_data_ui/src/component.rs index 22c10422524f..569e14121307 100644 --- a/crates/viewer/re_data_ui/src/component.rs +++ b/crates/viewer/re_data_ui/src/component.rs @@ -3,7 +3,7 @@ use egui::NumExt; use re_chunk_store::UnitChunkShared; use re_entity_db::InstancePath; use re_log_types::{ComponentPath, Instance, TimeInt}; -use re_ui::{ContextExt as _, SyntaxHighlighting as _}; +use re_ui::{ContextExt as _, SyntaxHighlighting as _, UiExt}; use re_viewer_context::{UiLayout, ViewerContext}; use super::DataUi; @@ -81,11 +81,11 @@ impl<'a> DataUi for ComponentPathLatestAtResults<'a> { *component_name, ); if temporal_message_count > 0 { - ui.label(ui.ctx().error_text(format!( + ui.error_label(&format!( "Static component has {} event{} logged on timelines", temporal_message_count, if temporal_message_count > 1 { "s" } else { "" } - ))) + )) .on_hover_text( "Components should be logged either as static or on timelines, but \ never both. Values for static components logged to timelines cannot be \ diff --git a/crates/viewer/re_data_ui/src/component_path.rs b/crates/viewer/re_data_ui/src/component_path.rs index c4cdf023acb0..5296f03d2a06 100644 --- a/crates/viewer/re_data_ui/src/component_path.rs +++ b/crates/viewer/re_data_ui/src/component_path.rs @@ -1,5 +1,5 @@ use re_log_types::ComponentPath; -use re_ui::ContextExt as _; +use re_ui::UiExt; use re_viewer_context::{UiLayout, ViewerContext}; use super::DataUi; @@ -47,10 +47,7 @@ impl DataUi for ComponentPath { )); } } else { - ui.label( - ui.ctx() - .error_text(format!("Unknown component path: {self}")), - ); + ui.error_label(&format!("Unknown component path: {self}")); } } } diff --git a/crates/viewer/re_data_ui/src/instance_path.rs b/crates/viewer/re_data_ui/src/instance_path.rs index 8ccf894bf97d..7cfebdc0f0c5 100644 --- a/crates/viewer/re_data_ui/src/instance_path.rs +++ b/crates/viewer/re_data_ui/src/instance_path.rs @@ -10,7 +10,7 @@ use re_types::{ image::ImageKind, static_assert_struct_has_fields, Archetype, ComponentName, Loggable, }; -use re_ui::{ContextExt as _, UiExt as _}; +use re_ui::UiExt as _; use re_viewer_context::{ gpu_bridge::image_data_range_heuristic, ColormapWithRange, HoverHighlight, ImageInfo, ImageStatsCache, Item, UiLayout, ViewerContext, @@ -46,11 +46,7 @@ impl DataUi for InstancePath { .store() .all_components_on_timeline(&query.timeline(), entity_path) } else { - ui_layout.label( - ui, - ui.ctx() - .error_text(format!("Unknown entity: {entity_path:?}")), - ); + ui.error_label(&format!("Unknown entity: {entity_path:?}")); return; }; let Some(components) = component else { diff --git a/crates/viewer/re_space_view_spatial/src/ui.rs b/crates/viewer/re_space_view_spatial/src/ui.rs index 1b9ce1c9cc45..77608d03f4dc 100644 --- a/crates/viewer/re_space_view_spatial/src/ui.rs +++ b/crates/viewer/re_space_view_spatial/src/ui.rs @@ -7,7 +7,7 @@ use re_types::{ archetypes::Pinhole, blueprint::components::VisualBounds2D, components::ViewCoordinates, image::ImageKind, }; -use re_ui::{ContextExt as _, UiExt as _}; +use re_ui::UiExt as _; use re_viewer_context::{ HoverHighlight, SelectionHighlight, SpaceViewHighlights, SpaceViewState, ViewerContext, }; @@ -214,11 +214,12 @@ pub fn create_labels( }; let font_id = egui::TextStyle::Body.resolve(parent_ui.style()); - let format = match label.style { - UiLabelStyle::Color(color) => egui::TextFormat::simple(font_id, color), - UiLabelStyle::Error => parent_ui.ctx().error_text_format(), + let is_error = matches!(label.style, UiLabelStyle::Error); + let text_color = match label.style { + UiLabelStyle::Color(color) => color, + UiLabelStyle::Error => parent_ui.style().visuals.strong_text_color(), }; - let text_color = format.color; + let format = egui::TextFormat::simple(font_id, text_color); let galley = parent_ui.fonts(|fonts| { fonts.layout_job({ @@ -249,7 +250,13 @@ pub fn create_labels( .index_highlight(label.labeled_instance.instance); let background_color = match highlight.hover { HoverHighlight::None => match highlight.selection { - SelectionHighlight::None => parent_ui.style().visuals.panel_fill, + SelectionHighlight::None => { + if is_error { + parent_ui.error_label_background_color() + } else { + parent_ui.style().visuals.panel_fill + } + } SelectionHighlight::SiblingSelection => { parent_ui.style().visuals.widgets.active.bg_fill } @@ -258,7 +265,16 @@ pub fn create_labels( HoverHighlight::Hovered => parent_ui.style().visuals.widgets.hovered.bg_fill, }; - label_shapes.push(egui::Shape::rect_filled(bg_rect, 3.0, background_color)); + let rect_stroke = if is_error { + egui::Stroke::new(1.0, parent_ui.style().visuals.error_fg_color) + } else { + egui::Stroke::NONE + }; + + label_shapes.push( + egui::epaint::RectShape::new(bg_rect.expand(4.0), 4.0, background_color, rect_stroke) + .into(), + ); label_shapes.push(egui::Shape::galley( text_rect.center_top(), galley, diff --git a/crates/viewer/re_space_view_tensor/src/space_view_class.rs b/crates/viewer/re_space_view_tensor/src/space_view_class.rs index 1b3c770a135e..4a6a84ca6bb9 100644 --- a/crates/viewer/re_space_view_tensor/src/space_view_class.rs +++ b/crates/viewer/re_space_view_tensor/src/space_view_class.rs @@ -13,7 +13,7 @@ use re_types::{ datatypes::{TensorData, TensorDimension}, SpaceViewClassIdentifier, View, }; -use re_ui::{list_item, ContextExt as _, UiExt as _}; +use re_ui::{list_item, UiExt as _}; use re_viewer_context::{ gpu_bridge, ApplicableEntities, ColormapWithRange, IdentifiedViewSystem as _, IndicatedEntities, PerVisualizer, SpaceViewClass, SpaceViewClassRegistryError, SpaceViewId, @@ -282,7 +282,7 @@ impl TensorSpaceView { if let Err(err) = self.tensor_slice_ui(ctx, ui, state, view_id, dimension_labels, &slice_selection) { - ui.label(ui.ctx().error_text(err.to_string())); + ui.error_label(&err.to_string()); } }); 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 1ebd8672cac0..6f0cea91f006 100644 --- a/crates/viewer/re_ui/examples/re_ui_example/main.rs +++ b/crates/viewer/re_ui/examples/re_ui_example/main.rs @@ -457,14 +457,19 @@ impl egui_tiles::Behavior for MyTileTreeBehavior { _tile_id: egui_tiles::TileId, _pane: &mut Tab, ) -> egui_tiles::UiResponse { - egui::warn_if_debug_build(ui); - ui.label("Hover me for a tooltip") - .on_hover_text("This is a tooltip"); - - ui.label( - egui::RichText::new("Welcome to the ReUi example") - .text_style(DesignTokens::welcome_screen_h1()), - ); + egui::Frame::none().inner_margin(4.0).show(ui, |ui| { + egui::warn_if_debug_build(ui); + ui.label("Hover me for a tooltip") + .on_hover_text("This is a tooltip"); + + ui.label( + egui::RichText::new("Welcome to the ReUi example") + .text_style(DesignTokens::welcome_screen_h1()), + ); + + ui.error_label("This is an example of a long error label."); + ui.warning_label("This is an example of a long warning label."); + }); Default::default() } diff --git a/crates/viewer/re_ui/src/command.rs b/crates/viewer/re_ui/src/command.rs index ed50f3c22a8c..c58b06069dc2 100644 --- a/crates/viewer/re_ui/src/command.rs +++ b/crates/viewer/re_ui/src/command.rs @@ -310,7 +310,14 @@ impl UICommand { Self::ToggleSelectionPanel => Some(ctrl_shift(Key::S)), Self::ToggleTimePanel => Some(ctrl_shift(Key::T)), Self::ToggleChunkStoreBrowser => Some(ctrl_shift(Key::D)), - Self::Settings => None, + Self::Settings => { + if cfg!(target_os = "macos") { + Some(KeyboardShortcut::new(Modifiers::MAC_CMD, Key::Comma)) + } else { + // TODO(emilk): shortcut for web and non-mac too + None + } + } #[cfg(debug_assertions)] Self::ToggleBlueprintInspectionPanel => Some(ctrl_shift(Key::I)), diff --git a/crates/viewer/re_ui/src/context_ext.rs b/crates/viewer/re_ui/src/context_ext.rs index 1132bd7e585d..7d83f269cbdd 100644 --- a/crates/viewer/re_ui/src/context_ext.rs +++ b/crates/viewer/re_ui/src/context_ext.rs @@ -59,35 +59,32 @@ pub trait ContextExt { // egui::Stroke::new(stroke_width, color) } + /// Text colored to indicate success. #[must_use] fn success_text(&self, text: impl Into) -> egui::RichText { egui::RichText::new(text).color(SUCCESS_COLOR) } + /// Text colored to indicate a warning. + /// + /// For most cases, you should use [`crate::UiExt::warning_label`] instead, + /// which has a nice fat border around it. #[must_use] fn warning_text(&self, text: impl Into) -> egui::RichText { let style = self.ctx().style(); egui::RichText::new(text).color(style.visuals.warn_fg_color) } - /// NOTE: duplicated in [`Self::error_text_format`] + /// Text colored to indicate an error. + /// + /// For most cases, you should use [`crate::UiExt::error_label`] instead, + /// which has a nice fat border around it. #[must_use] fn error_text(&self, text: impl Into) -> egui::RichText { let style = self.ctx().style(); egui::RichText::new(text).color(style.visuals.error_fg_color) } - /// NOTE: duplicated in [`Self::error_text`] - fn error_text_format(&self) -> egui::TextFormat { - let style = self.ctx().style(); - let font_id = egui::TextStyle::Body.resolve(&style); - egui::TextFormat { - font_id, - color: style.visuals.error_fg_color, - ..Default::default() - } - } - fn top_bar_style(&self, style_like_web: bool) -> TopBarStyle { let egui_zoom_factor = self.ctx().zoom_factor(); let fullscreen = self diff --git a/crates/viewer/re_ui/src/design_tokens.rs b/crates/viewer/re_ui/src/design_tokens.rs index 4f67fc7c709d..274d20f0c567 100644 --- a/crates/viewer/re_ui/src/design_tokens.rs +++ b/crates/viewer/re_ui/src/design_tokens.rs @@ -242,6 +242,9 @@ impl DesignTokens { egui_style.visuals.image_loading_spinners = false; + egui_style.visuals.error_fg_color = egui::Color32::from_rgb(0xAB, 0x01, 0x16); + egui_style.visuals.warn_fg_color = egui::Color32::from_rgb(0xFF, 0x7A, 0x0C); + ctx.set_style(egui_style); } diff --git a/crates/viewer/re_ui/src/toasts.rs b/crates/viewer/re_ui/src/toasts.rs index c78ca288c382..82773d03a38b 100644 --- a/crates/viewer/re_ui/src/toasts.rs +++ b/crates/viewer/re_ui/src/toasts.rs @@ -5,9 +5,7 @@ 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); +pub const SUCCESS_COLOR: Color32 = Color32::from_rgb(0, 240, 32); #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum ToastKind { @@ -139,8 +137,8 @@ fn default_toast_contents(ui: &mut egui::Ui, toast: &Toast) -> egui::Response { if toast.options.show_icon { let (icon, icon_color) = match toast.kind { - ToastKind::Warning => ("⚠", WARNING_COLOR), - ToastKind::Error => ("❗", ERROR_COLOR), + ToastKind::Warning => ("⚠", ui.style().visuals.warn_fg_color), + ToastKind::Error => ("❗", ui.style().visuals.error_fg_color), ToastKind::Success => ("✔", SUCCESS_COLOR), _ => ("ℹ", INFO_COLOR), }; diff --git a/crates/viewer/re_ui/src/ui_ext.rs b/crates/viewer/re_ui/src/ui_ext.rs index 840f86841816..58b7cfcf804a 100644 --- a/crates/viewer/re_ui/src/ui_ext.rs +++ b/crates/viewer/re_ui/src/ui_ext.rs @@ -6,54 +6,112 @@ use egui::{ }; use crate::{ - design_tokens, icons, list_item, - list_item::{LabelContent, ListItem}, - ContextExt, DesignTokens, Icon, LabelStyle, + design_tokens, icons, + list_item::{self, LabelContent, ListItem}, + toasts::SUCCESS_COLOR, + DesignTokens, Icon, LabelStyle, }; static FULL_SPAN_TAG: &str = "rerun_full_span"; +fn error_label_bg_color(fg_color: Color32) -> Color32 { + fg_color.gamma_multiply(0.35) +} + +/// success, warning, error… +fn notification_label( + ui: &mut egui::Ui, + fg_color: Color32, + icon: &str, + visible_text: &str, + full_text: &str, +) -> egui::Response { + egui::Frame::none() + .stroke((1.0, fg_color)) + .fill(error_label_bg_color(fg_color)) + .rounding(4.0) + .inner_margin(3.0) + .outer_margin(1.0) // Needed because we set clip_rect_margin. TODO(emilk): https://github.com/emilk/egui/issues/4019 + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.colored_label(fg_color, icon); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + let response = ui.strong(visible_text).on_hover_ui(|ui| { + if visible_text != full_text { + ui.label(full_text); + ui.add_space(8.0); + } + ui.label("Click to copy text."); + }); + if response.clicked() { + ui.ctx().copy_text(full_text.to_owned()); + }; + }); + }) + .response +} + /// Rerun custom extensions to [`egui::Ui`]. pub trait UiExt { fn ui(&self) -> &egui::Ui; fn ui_mut(&mut self) -> &mut egui::Ui; - /// Show the label with a success color. - fn success_label(&mut self, text: &str) -> egui::Response { - let label = egui::Label::new(self.ui().ctx().success_text(text)); - self.ui_mut().add(label) + /// Shows a success label with a large border. + /// + /// If you don't want a border, use [`crate::ContextExt::success_text`]. + fn success_label(&mut self, success_text: &str) -> egui::Response { + let ui = self.ui_mut(); + notification_label(ui, SUCCESS_COLOR, "✅", success_text, success_text) } - /// Shows a warning label. + /// Shows a warning label with a large border. + /// + /// If you don't want a border, use [`crate::ContextExt::warning_text`]. fn warning_label(&mut self, warning_text: &str) -> egui::Response { - let label = egui::Label::new(self.ui().ctx().warning_text(warning_text)); - self.ui_mut().add(label) + let ui = self.ui_mut(); + notification_label( + ui, + ui.style().visuals.warn_fg_color, + "⚠", + warning_text, + warning_text, + ) } - /// Shows a small error label with the given text on hover and copies the text to the clipboard on click. + /// Shows a small error label with the given text on hover and copies the text to the clipboard on click with a large border. + /// + /// This has a large border! If you don't want a border, use [`crate::ContextExt::error_text`]. fn error_with_details_on_hover(&mut self, error_text: &str) -> egui::Response { - let label = egui::Label::new(self.ui().ctx().error_text("Error")) - .selectable(false) - .sense(egui::Sense::click()); - let response = self.ui_mut().add(label); - if response.clicked() { - self.ui().ctx().copy_text(error_text.to_owned()); - } - response.on_hover_text(error_text) + let ui = self.ui_mut(); + notification_label( + ui, + ui.style().visuals.error_fg_color, + "⚠", + "Error", + error_text, + ) + } + + fn error_label_background_color(&self) -> egui::Color32 { + error_label_bg_color(self.ui().style().visuals.error_fg_color) } /// Shows an error label with the entire error text and copies the text to the clipboard on click. /// - /// Use this only if you have a lot of space to spare. + /// Use this only if the error message is short, or you have a lot of room. + /// Otherwise, use [`Self::error_with_details_on_hover`]. + /// + /// This has a large border! If you don't want a border, use [`crate::ContextExt::error_text`]. fn error_label(&mut self, error_text: &str) -> egui::Response { - let label = egui::Label::new(self.ui().ctx().error_text(error_text)) - .selectable(true) - .sense(egui::Sense::click()); - let response = self.ui_mut().add(label).on_hover_text("Click to copy."); - if response.clicked() { - self.ui().ctx().copy_text(error_text.to_owned()); - } - response + let ui = self.ui_mut(); + notification_label( + ui, + ui.style().visuals.error_fg_color, + "⚠", + error_text, + error_text, + ) } fn small_icon_button(&mut self, icon: &Icon) -> egui::Response { diff --git a/crates/viewer/re_viewer/src/ui/settings_screen.rs b/crates/viewer/re_viewer/src/ui/settings_screen.rs index 61833896562d..6053d0550eab 100644 --- a/crates/viewer/re_viewer/src/ui/settings_screen.rs +++ b/crates/viewer/re_viewer/src/ui/settings_screen.rs @@ -210,34 +210,32 @@ fn ffmpeg_path_status_ui(ui: &mut Ui, app_options: &AppOptions) { .video_decoder_override_ffmpeg_path .then(|| std::path::Path::new(&app_options.video_decoder_ffmpeg_path)); - if let Some(path) = path { - if !path.is_file() { - ui.error_label("The specified FFmpeg binary path does not exist or is not a file."); - return; - } - } - - let res = FFmpegVersion::for_executable(path); - match res { - Ok(version) => { - if version.is_compatible() { - ui.success_label(&format!("FFmpeg found (version {version})")); - } else { - ui.error_label(&format!("Incompatible FFmpeg version: {version}")); + if path.is_some_and(|path| !path.is_file()) { + ui.error_label("The specified FFmpeg binary path does not exist or is not a file."); + } else { + let res = FFmpegVersion::for_executable(path); + + match res { + Ok(version) => { + if version.is_compatible() { + ui.success_label(&format!("FFmpeg found (version {version})")); + } else { + ui.error_label(&format!("Incompatible FFmpeg version: {version}")); + } + } + Err(FFmpegVersionParseError::ParseVersion { raw_version }) => { + // We make this one a warning instead of an error because version parsing is flaky, and + // it might end up still working. + ui.warning_label(&format!( + "FFmpeg binary found but unable to parse version: {raw_version}" + )); } - } - Err(FFmpegVersionParseError::ParseVersion { raw_version }) => { - // We make this one a warning instead of an error because version parsing is flaky, and - // it might end up still working. - ui.warning_label(&format!( - "FFmpeg binary found but unable to parse version: {raw_version}" - )); - } - Err(err) => { - ui.error_label(&format!("Unable to check FFmpeg version: {err}")); + Err(err) => { + ui.error_label(&format!("Unable to check FFmpeg version: {err}")); + } } - } + }; } fn separator_with_some_space(ui: &mut egui::Ui) {