├── .gitignore ├── media ├── toasts_type.png └── toasts_example_video.gif ├── .github └── workflows │ └── rust.yml ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── anchor.rs ├── toast.rs └── lib.rs └── examples └── all.rs /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | /target -------------------------------------------------------------------------------- /media/toasts_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsEthra/egui-notify/HEAD/media/toasts_type.png -------------------------------------------------------------------------------- /media/toasts_example_video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ItsEthra/egui-notify/HEAD/media/toasts_example_video.gif -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | ci: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Check formatting 19 | run: cargo fmt --check --verbose 20 | - name: Build 21 | run: cargo build --verbose 22 | - name: Check linting 23 | run: cargo clippy -- -D warnings 24 | - name: Run tests 25 | run: cargo test --verbose 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | * Add `len` & `is_empty` functions 4 | 5 | # 0.17.0 6 | * (breaking) removed `Toast::font(font: FontId)`, this can now be done by using `egui::widget_text::RichText` and `RichText::font`. [#34] 7 | * Added support for `egui::widget_text::WidgetText` in Toasts, this allows for more customization of the text in the toast. [#34] 8 | 9 | [#34]: https://github.com/ItsEthra/egui-notify/pull/34 10 | 11 | # 0.16.0 12 | 13 | * (breaking) Updated to egui `0.29`. 14 | * (breaking) Renamed functions, removed `set_` prefix to fit egui style. [#29] 15 | * Accept either `None` or `Some(duration)` in `set_duration`. [#31] 16 | * Enable shadow beneath toasts using `with_shadow` [#33] 17 | 18 | [#29]: https://github.com/ItsEthra/egui-notify/pull/29 19 | [#31]: https://github.com/ItsEthra/egui-notify/pull/31 20 | [#33]: https://github.com/ItsEthra/egui-notify/pull/33 21 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "egui-notify" 3 | version = "0.21.0" 4 | edition = "2021" 5 | license = "MIT" 6 | repository = "https://github.com/ItsEthra/egui-notify" 7 | description = "Simple notifications library for egui" 8 | authors = ["ItsEthra"] 9 | readme = "README.md" 10 | 11 | [lib] 12 | path = "src/lib.rs" 13 | 14 | [dependencies] 15 | egui = { version = "0.33", default-features = false } 16 | 17 | [dev-dependencies] 18 | eframe = { version = "0.33", default-features = false, features = [ 19 | "default_fonts", 20 | "glow", 21 | "x11", 22 | "wayland", 23 | ] } 24 | egui-phosphor = { git = "https://github.com/ItsEthra/egui-phosphor.git", branch = "egui-0.33" } 25 | 26 | [lints.rust] 27 | unsafe_code = "forbid" 28 | 29 | [lints.clippy] 30 | all = { level = "deny", priority = 0 } 31 | unwrap_used = { level = "deny", priority = 1 } 32 | expect_used = { level = "deny", priority = 1 } 33 | enum_glob_use = { level = "deny", priority = 2 } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 ItsEthra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # egui-notify 2 | 3 | Simple notifications library for [`egui`](https://github.com/emilk/egui) 4 | 5 | ![example_image](media/toasts_type.png) 6 | 7 | ![example_video](media/toasts_example_video.gif) 8 | 9 | ## Usage 10 | 11 | ```rust 12 | use egui_notify::Toasts; 13 | use std::time::Duration; 14 | 15 | // initialize once 16 | let mut toasts = Toasts::default(); 17 | ``` 18 | 19 | ```rust 20 | // somewhere within [egui::App::update]... 21 | toasts.info("Hello world!").duration(Duration::from_secs(5)); 22 | // ... 23 | toasts.show(ctx); 24 | ``` 25 | 26 | ## Installation 27 | 28 | ```sh 29 | cargo add egui-notify 30 | ``` 31 | 32 | ```toml 33 | [dependencies] 34 | egui-notify = "*" # replace with the latest version 35 | ``` 36 | 37 | ## Difference to [`egui-toast`](https://github.com/urholaukkarinen/egui-toast) 38 | 39 | ### `egui-notify` has 40 | 41 | - Animations for appearing/disappearing toasts 42 | - Duration meter for expiring toasts 43 | - Toast positioning not influenced by which `Context` you pass to it (like if for example, you passed in a `Context` already altered for an `egui::Window`) 44 | - Differing methodology (create `Toasts` instance once, save save somewhere in application state) 45 | - Threadsafe `Toasts` instance, implements `Send`, `Sync`. 46 | - No support for custom toasts 47 | -------------------------------------------------------------------------------- /src/anchor.rs: -------------------------------------------------------------------------------- 1 | use egui::{pos2, Pos2, Vec2}; 2 | 3 | /// Anchor where to show toasts 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum Anchor { 6 | /// Top right corner. 7 | TopRight, 8 | /// Top left corner. 9 | TopLeft, 10 | /// Bottom right corner. 11 | BottomRight, 12 | /// Bottom left corner 13 | BottomLeft, 14 | } 15 | 16 | impl Anchor { 17 | #[inline] 18 | pub(crate) const fn anim_side(&self) -> f32 { 19 | match self { 20 | Self::TopRight | Self::BottomRight => 1., 21 | Self::TopLeft | Self::BottomLeft => -1., 22 | } 23 | } 24 | } 25 | 26 | impl Anchor { 27 | pub(crate) fn screen_corner(&self, sc: Pos2, margin: Vec2) -> Pos2 { 28 | let mut out = match self { 29 | Self::TopRight => pos2(sc.x, 0.), 30 | Self::TopLeft => pos2(0., 0.), 31 | Self::BottomRight => sc, 32 | Self::BottomLeft => pos2(0., sc.y), 33 | }; 34 | self.apply_margin(&mut out, margin); 35 | out 36 | } 37 | 38 | pub(crate) fn apply_margin(&self, pos: &mut Pos2, margin: Vec2) { 39 | match self { 40 | Self::TopRight => { 41 | pos.x -= margin.x; 42 | pos.y += margin.y; 43 | } 44 | Self::TopLeft => { 45 | pos.x += margin.x; 46 | pos.y += margin.y; 47 | } 48 | Self::BottomRight => { 49 | pos.x -= margin.x; 50 | pos.y -= margin.y; 51 | } 52 | Self::BottomLeft => { 53 | pos.x += margin.x; 54 | pos.y -= margin.y; 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/all.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::FontDefinitions; 2 | use eframe::{ 3 | egui::{Context, Slider, Window}, 4 | App, Frame, NativeOptions, 5 | }; 6 | use egui::{Color32, Shadow, Style, Visuals}; 7 | use egui_notify::{Toast, Toasts}; 8 | use std::time::Duration; 9 | 10 | struct ExampleApp { 11 | toasts: Toasts, 12 | caption: String, 13 | closable: bool, 14 | show_progress_bar: bool, 15 | expires: bool, 16 | duration: f32, 17 | font_size: f32, 18 | dark: bool, 19 | custom_level_string: String, 20 | custom_level_color: Color32, 21 | shadow: bool, 22 | } 23 | 24 | impl App for ExampleApp { 25 | fn update(&mut self, ctx: &Context, _: &mut Frame) { 26 | Window::new("Controls").show(ctx, |ui| { 27 | ui.text_edit_multiline(&mut self.caption); 28 | ui.checkbox(&mut self.expires, "Expires"); 29 | ui.checkbox(&mut self.closable, "Closable"); 30 | ui.checkbox(&mut self.show_progress_bar, "ShowProgressBar"); 31 | ui.checkbox(&mut self.shadow, "Shadow").clicked().then(|| { 32 | self.toasts = if self.shadow { 33 | Toasts::default().with_shadow(Shadow { 34 | offset: Default::default(), 35 | blur: 30, 36 | spread: 5, 37 | color: Color32::from_black_alpha(70), 38 | }) 39 | } else { 40 | Toasts::default() 41 | }; 42 | }); 43 | if !(self.expires || self.closable) { 44 | ui.label("Warning; toasts will have to be closed programatically"); 45 | } 46 | ui.add_enabled_ui(self.expires, |ui| { 47 | ui.horizontal(|ui| { 48 | ui.label("Duration (in s)"); 49 | ui.add(Slider::new(&mut self.duration, 1.0..=10.0)); 50 | }); 51 | ui.horizontal(|ui| { 52 | ui.label("Font size"); 53 | ui.add(Slider::new(&mut self.font_size, 8.0..=20.0)); 54 | }); 55 | }); 56 | ui.text_edit_singleline(&mut self.custom_level_string); 57 | ui.color_edit_button_srgba(&mut self.custom_level_color); 58 | 59 | let customize_toast = |t: &mut Toast| { 60 | let duration = if self.expires { 61 | Some(Duration::from_millis((1000. * self.duration) as u64)) 62 | } else { 63 | None 64 | }; 65 | 66 | t.closable(self.closable) 67 | .duration(duration) 68 | .show_progress_bar(self.show_progress_bar); 69 | }; 70 | 71 | ui.horizontal(|ui| { 72 | if ui.button("Success").clicked() { 73 | customize_toast(self.toasts.success(self.caption.clone())); 74 | } 75 | 76 | if ui.button("Info").clicked() { 77 | customize_toast(self.toasts.info(self.caption.clone())); 78 | } 79 | 80 | if ui.button("Warning").clicked() { 81 | customize_toast(self.toasts.warning(self.caption.clone())); 82 | } 83 | 84 | if ui.button("Error").clicked() { 85 | customize_toast(self.toasts.error(self.caption.clone())); 86 | } 87 | 88 | if ui.button("Basic").clicked() { 89 | customize_toast(self.toasts.basic(self.caption.clone())); 90 | } 91 | 92 | if ui.button("Rich text").clicked() { 93 | customize_toast( 94 | self.toasts.success( 95 | egui::RichText::new(self.caption.clone()) 96 | .color(Color32::GREEN) 97 | .background_color(Color32::DARK_GRAY) 98 | .size(self.font_size) 99 | .italics() 100 | .underline(), 101 | ), 102 | ); 103 | } 104 | 105 | if ui.button("Custom").clicked() { 106 | customize_toast(self.toasts.custom( 107 | self.caption.clone(), 108 | self.custom_level_string.clone(), 109 | self.custom_level_color, 110 | )); 111 | } 112 | 113 | if ui 114 | .button("Phosphor") 115 | .on_hover_text("This toast uses egui-phosphor icons") 116 | .clicked() 117 | { 118 | customize_toast(self.toasts.custom( 119 | self.caption.clone(), 120 | egui_phosphor::regular::FAN.to_owned(), 121 | self.custom_level_color, 122 | )); 123 | } 124 | }); 125 | 126 | ui.separator(); 127 | 128 | if ui.button("Dismiss all toasts").clicked() { 129 | self.toasts.dismiss_all_toasts(); 130 | } 131 | if ui.button("Dismiss latest toast").clicked() { 132 | self.toasts.dismiss_latest_toast(); 133 | } 134 | if ui.button("Dismiss oldest toast").clicked() { 135 | self.toasts.dismiss_oldest_toast(); 136 | } 137 | 138 | ui.separator(); 139 | 140 | if ui.radio(self.dark, "Toggle dark theme").clicked() { 141 | self.dark = !self.dark; 142 | 143 | let mut style = ctx.style().as_ref().clone(); 144 | if self.dark { 145 | style.visuals = Visuals::dark(); 146 | } else { 147 | style.visuals = Visuals::light(); 148 | } 149 | ctx.set_style(style); 150 | } 151 | }); 152 | 153 | self.toasts.show(ctx); 154 | } 155 | } 156 | 157 | fn main() -> eframe::Result<()> { 158 | eframe::run_native( 159 | "example", 160 | NativeOptions::default(), 161 | Box::new(|cc| { 162 | cc.egui_ctx.set_style(Style::default()); 163 | 164 | let mut font_def = FontDefinitions::default(); 165 | egui_phosphor::add_to_fonts(&mut font_def, egui_phosphor::Variant::Regular); 166 | // for data in font_def.font_data.values_mut() { 167 | // data.tweak.scale = 1.25; 168 | // } 169 | cc.egui_ctx.set_fonts(font_def); 170 | 171 | Ok(Box::new(ExampleApp { 172 | caption: r#"Hello! It's a multiline caption 173 | Next line 174 | Another one 175 | And another one"# 176 | .into(), 177 | toasts: Toasts::default(), 178 | closable: true, 179 | expires: true, 180 | show_progress_bar: true, 181 | duration: 3.5, 182 | dark: true, 183 | font_size: 16., 184 | custom_level_string: "$".into(), 185 | custom_level_color: egui::Color32::GREEN, 186 | shadow: true, 187 | })) 188 | }), 189 | ) 190 | } 191 | -------------------------------------------------------------------------------- /src/toast.rs: -------------------------------------------------------------------------------- 1 | use crate::{Anchor, TOAST_HEIGHT, TOAST_WIDTH}; 2 | use egui::{pos2, vec2, Color32, Pos2, Rect, WidgetText}; 3 | use std::{fmt::Debug, time::Duration}; 4 | 5 | /// Level of importance 6 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 7 | #[allow(missing_docs)] 8 | pub enum ToastLevel { 9 | #[default] 10 | Info, 11 | Warning, 12 | Error, 13 | Success, 14 | None, 15 | Custom(String, Color32), 16 | } 17 | 18 | #[derive(Debug)] 19 | /// State of the toast 20 | pub enum ToastState { 21 | /// Toast is appearing 22 | Appear, 23 | /// Toast is disappearing 24 | Disappear, 25 | /// Toast has disappeared 26 | Disappeared, 27 | /// Toast is idling 28 | Idle, 29 | } 30 | 31 | impl ToastState { 32 | /// Returns `true` if the toast is appearing 33 | pub const fn appearing(&self) -> bool { 34 | matches!(self, Self::Appear) 35 | } 36 | 37 | /// Returns `true` if the toast is disappearing 38 | pub const fn disappearing(&self) -> bool { 39 | matches!(self, Self::Disappear) 40 | } 41 | 42 | /// Returns `true` if the toast has disappeared 43 | pub const fn disappeared(&self) -> bool { 44 | matches!(self, Self::Disappeared) 45 | } 46 | 47 | /// Returns `true` if the toast is idling 48 | pub const fn idling(&self) -> bool { 49 | matches!(self, Self::Idle) 50 | } 51 | } 52 | 53 | /// Container for options for initlizing toasts 54 | pub struct ToastOptions { 55 | duration: Option, 56 | level: ToastLevel, 57 | closable: bool, 58 | show_progress_bar: bool, 59 | } 60 | 61 | /// Single notification or *toast* 62 | pub struct Toast { 63 | pub(crate) level: ToastLevel, 64 | pub(crate) caption: WidgetText, 65 | // (initial, current) 66 | pub(crate) duration: Option<(f32, f32)>, 67 | pub(crate) height: f32, 68 | pub(crate) width: f32, 69 | pub(crate) closable: bool, 70 | pub(crate) show_progress_bar: bool, 71 | 72 | pub(crate) state: ToastState, 73 | pub(crate) value: f32, 74 | } 75 | 76 | impl Default for ToastOptions { 77 | fn default() -> Self { 78 | Self { 79 | duration: Some(Duration::from_millis(3500)), 80 | level: ToastLevel::None, 81 | closable: true, 82 | show_progress_bar: true, 83 | } 84 | } 85 | } 86 | 87 | fn duration_to_seconds_f32(duration: Duration) -> f32 { 88 | duration.as_nanos() as f32 * 1e-9 89 | } 90 | 91 | impl Toast { 92 | fn new(caption: impl Into, options: ToastOptions) -> Self { 93 | Self { 94 | caption: caption.into(), 95 | height: TOAST_HEIGHT, 96 | width: TOAST_WIDTH, 97 | duration: options.duration.map(|dur| { 98 | let max_dur = duration_to_seconds_f32(dur); 99 | (max_dur, max_dur) 100 | }), 101 | closable: options.closable, 102 | show_progress_bar: options.show_progress_bar, 103 | level: options.level, 104 | value: 0., 105 | state: ToastState::Appear, 106 | } 107 | } 108 | 109 | /// Creates new basic toast, can be closed by default. 110 | pub fn basic(caption: impl Into) -> Self { 111 | Self::new(caption, ToastOptions::default()) 112 | } 113 | 114 | /// Creates new success toast, can be closed by default. 115 | pub fn success(caption: impl Into) -> Self { 116 | Self::new( 117 | caption, 118 | ToastOptions { 119 | level: ToastLevel::Success, 120 | ..ToastOptions::default() 121 | }, 122 | ) 123 | } 124 | 125 | /// Creates new info toast, can be closed by default. 126 | pub fn info(caption: impl Into) -> Self { 127 | Self::new( 128 | caption, 129 | ToastOptions { 130 | level: ToastLevel::Info, 131 | ..ToastOptions::default() 132 | }, 133 | ) 134 | } 135 | 136 | /// Creates new warning toast, can be closed by default. 137 | pub fn warning(caption: impl Into) -> Self { 138 | Self::new( 139 | caption, 140 | ToastOptions { 141 | level: ToastLevel::Warning, 142 | ..ToastOptions::default() 143 | }, 144 | ) 145 | } 146 | 147 | /// Creates new error toast, can not be closed by default. 148 | pub fn error(caption: impl Into) -> Self { 149 | Self::new( 150 | caption, 151 | ToastOptions { 152 | closable: false, 153 | level: ToastLevel::Error, 154 | ..ToastOptions::default() 155 | }, 156 | ) 157 | } 158 | 159 | /// Creates new custom toast, can be closed by default. 160 | pub fn custom(caption: impl Into, level: ToastLevel) -> Self { 161 | Self::new( 162 | caption, 163 | ToastOptions { 164 | level, 165 | ..ToastOptions::default() 166 | }, 167 | ) 168 | } 169 | 170 | /// Set the options with a [`ToastOptions`] 171 | pub fn options(&mut self, options: ToastOptions) -> &mut Self { 172 | self.closable(options.closable); 173 | self.duration(options.duration); 174 | self.level(options.level); 175 | self 176 | } 177 | 178 | /// Change the level of the toast 179 | pub fn level(&mut self, level: ToastLevel) -> &mut Self { 180 | self.level = level; 181 | self 182 | } 183 | 184 | /// Can the user close the toast? 185 | pub fn closable(&mut self, closable: bool) -> &mut Self { 186 | self.closable = closable; 187 | self 188 | } 189 | 190 | /// Should a progress bar be shown? 191 | pub fn show_progress_bar(&mut self, show_progress_bar: bool) -> &mut Self { 192 | self.show_progress_bar = show_progress_bar; 193 | self 194 | } 195 | 196 | /// In what time should the toast expire? Set to `None` for no expiry. 197 | pub fn duration(&mut self, duration: impl Into>) -> &mut Self { 198 | if let Some(duration) = duration.into() { 199 | let max_dur = duration_to_seconds_f32(duration); 200 | self.duration = Some((max_dur, max_dur)); 201 | } else { 202 | self.duration = None; 203 | } 204 | self 205 | } 206 | 207 | /// Toast's box height 208 | pub fn height(&mut self, height: f32) -> &mut Self { 209 | self.height = height; 210 | self 211 | } 212 | 213 | /// Toast's box width 214 | pub fn width(&mut self, width: f32) -> &mut Self { 215 | self.width = width; 216 | self 217 | } 218 | 219 | /// Dismiss this toast 220 | pub fn dismiss(&mut self) { 221 | self.state = ToastState::Disappear; 222 | } 223 | 224 | pub(crate) fn calc_anchored_rect(&self, pos: Pos2, anchor: Anchor) -> Rect { 225 | match anchor { 226 | Anchor::TopRight => Rect { 227 | min: pos2(pos.x - self.width, pos.y), 228 | max: pos2(pos.x, pos.y + self.height), 229 | }, 230 | Anchor::TopLeft => Rect { 231 | min: pos, 232 | max: pos + vec2(self.width, self.height), 233 | }, 234 | Anchor::BottomRight => Rect { 235 | min: pos - vec2(self.width, self.height), 236 | max: pos, 237 | }, 238 | Anchor::BottomLeft => Rect { 239 | min: pos2(pos.x, pos.y - self.height), 240 | max: pos2(pos.x + self.width, pos.y), 241 | }, 242 | } 243 | } 244 | 245 | pub(crate) fn adjust_next_pos(&self, pos: &mut Pos2, anchor: Anchor, spacing: f32) { 246 | match anchor { 247 | Anchor::TopRight | Anchor::TopLeft => pos.y += self.height + spacing, 248 | Anchor::BottomRight | Anchor::BottomLeft => pos.y -= self.height + spacing, 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Simple notifications library for egui. 2 | 3 | #![warn(missing_docs)] 4 | 5 | mod toast; 6 | pub use toast::*; 7 | mod anchor; 8 | pub use anchor::*; 9 | 10 | #[doc(hidden)] 11 | pub use egui::__run_test_ctx; 12 | use egui::text::TextWrapping; 13 | use egui::{ 14 | vec2, Align, Color32, Context, CornerRadius, FontId, FontSelection, Id, LayerId, Order, Rect, 15 | Shadow, Stroke, TextWrapMode, Vec2, WidgetText, 16 | }; 17 | 18 | pub(crate) const TOAST_WIDTH: f32 = 180.; 19 | pub(crate) const TOAST_HEIGHT: f32 = 34.; 20 | 21 | const ERROR_COLOR: Color32 = Color32::from_rgb(200, 90, 90); 22 | const INFO_COLOR: Color32 = Color32::from_rgb(150, 200, 210); 23 | const WARNING_COLOR: Color32 = Color32::from_rgb(230, 220, 140); 24 | const SUCCESS_COLOR: Color32 = Color32::from_rgb(140, 230, 140); 25 | 26 | /// Main notifications collector. 27 | /// # Usage 28 | /// You need to create [`Toasts`] once and call `.show(ctx)` in every frame. 29 | /// ``` 30 | /// # use std::time::Duration; 31 | /// use egui_notify::Toasts; 32 | /// 33 | /// # egui_notify::__run_test_ctx(|ctx| { 34 | /// let mut t = Toasts::default(); 35 | /// t.info("Hello, World!").duration(Some(Duration::from_secs(5))).closable(true); 36 | /// // More app code 37 | /// t.show(ctx); 38 | /// # }); 39 | /// ``` 40 | pub struct Toasts { 41 | toasts: Vec, 42 | anchor: Anchor, 43 | margin: Vec2, 44 | spacing: f32, 45 | padding: Vec2, 46 | reverse: bool, 47 | speed: f32, 48 | font: Option, 49 | shadow: Option, 50 | held: bool, 51 | } 52 | 53 | impl Toasts { 54 | /// Creates new [`Toasts`] instance. 55 | #[must_use] 56 | pub const fn new() -> Self { 57 | Self { 58 | anchor: Anchor::TopRight, 59 | margin: vec2(8., 8.), 60 | toasts: vec![], 61 | spacing: 8., 62 | padding: vec2(10., 10.), 63 | held: false, 64 | speed: 4., 65 | reverse: false, 66 | font: None, 67 | shadow: None, 68 | } 69 | } 70 | 71 | /// Adds new toast to the collection. 72 | /// By default adds toast at the end of the list, can be changed with `self.reverse`. 73 | #[allow(clippy::unwrap_used)] // We know that the index is valid 74 | pub fn add(&mut self, toast: Toast) -> &mut Toast { 75 | if self.reverse { 76 | self.toasts.insert(0, toast); 77 | return self.toasts.get_mut(0).unwrap(); 78 | } 79 | self.toasts.push(toast); 80 | let l = self.toasts.len() - 1; 81 | self.toasts.get_mut(l).unwrap() 82 | } 83 | 84 | /// Dismisses the oldest toast 85 | pub fn dismiss_oldest_toast(&mut self) { 86 | if let Some(toast) = self.toasts.get_mut(0) { 87 | toast.dismiss(); 88 | } 89 | } 90 | 91 | /// Dismisses the most recent toast 92 | pub fn dismiss_latest_toast(&mut self) { 93 | if let Some(toast) = self.toasts.last_mut() { 94 | toast.dismiss(); 95 | } 96 | } 97 | 98 | /// Dismisses all toasts 99 | pub fn dismiss_all_toasts(&mut self) { 100 | for toast in &mut self.toasts { 101 | toast.dismiss(); 102 | } 103 | } 104 | 105 | /// Returns the number of toast items. 106 | pub fn len(&self) -> usize { 107 | self.toasts.len() 108 | } 109 | 110 | /// Returns `true` if there are no toast items. 111 | pub fn is_empty(&self) -> bool { 112 | self.toasts.is_empty() 113 | } 114 | 115 | /// Shortcut for adding a toast with info `success`. 116 | pub fn success(&mut self, caption: impl Into) -> &mut Toast { 117 | self.add(Toast::success(caption)) 118 | } 119 | 120 | /// Shortcut for adding a toast with info `level`. 121 | pub fn info(&mut self, caption: impl Into) -> &mut Toast { 122 | self.add(Toast::info(caption)) 123 | } 124 | 125 | /// Shortcut for adding a toast with warning `level`. 126 | pub fn warning(&mut self, caption: impl Into) -> &mut Toast { 127 | self.add(Toast::warning(caption)) 128 | } 129 | 130 | /// Shortcut for adding a toast with error `level`. 131 | pub fn error(&mut self, caption: impl Into) -> &mut Toast { 132 | self.add(Toast::error(caption)) 133 | } 134 | 135 | /// Shortcut for adding a toast with no level. 136 | pub fn basic(&mut self, caption: impl Into) -> &mut Toast { 137 | self.add(Toast::basic(caption)) 138 | } 139 | 140 | /// Shortcut for adding a toast with custom `level`. 141 | pub fn custom( 142 | &mut self, 143 | caption: impl Into, 144 | level_string: String, 145 | level_color: egui::Color32, 146 | ) -> &mut Toast { 147 | self.add(Toast::custom( 148 | caption, 149 | ToastLevel::Custom(level_string, level_color), 150 | )) 151 | } 152 | 153 | /// Should toasts be added in reverse order? 154 | pub const fn reverse(mut self, reverse: bool) -> Self { 155 | self.reverse = reverse; 156 | self 157 | } 158 | 159 | /// Where toasts should appear. 160 | pub const fn with_anchor(mut self, anchor: Anchor) -> Self { 161 | self.anchor = anchor; 162 | self 163 | } 164 | 165 | /// Sets spacing between adjacent toasts. 166 | pub const fn with_spacing(mut self, spacing: f32) -> Self { 167 | self.spacing = spacing; 168 | self 169 | } 170 | 171 | /// Margin or distance from screen to toasts' bounding boxes 172 | pub const fn with_margin(mut self, margin: Vec2) -> Self { 173 | self.margin = margin; 174 | self 175 | } 176 | 177 | /// Enables the use of a shadow for toasts. 178 | pub const fn with_shadow(mut self, shadow: Shadow) -> Self { 179 | self.shadow = Some(shadow); 180 | self 181 | } 182 | 183 | /// Padding or distance from toasts' bounding boxes to inner contents. 184 | pub const fn with_padding(mut self, padding: Vec2) -> Self { 185 | self.padding = padding; 186 | self 187 | } 188 | 189 | /// Changes the default font used for all toasts. 190 | pub fn with_default_font(mut self, font: FontId) -> Self { 191 | self.font = Some(font); 192 | self 193 | } 194 | } 195 | 196 | impl Toasts { 197 | /// Displays toast queue 198 | pub fn show(&mut self, ctx: &Context) { 199 | let Self { 200 | anchor, 201 | margin, 202 | spacing, 203 | padding, 204 | toasts, 205 | held, 206 | speed, 207 | .. 208 | } = self; 209 | 210 | let mut pos = anchor.screen_corner(ctx.input(|i| i.content_rect().max), *margin); 211 | let p = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("toasts"))); 212 | 213 | // `held` used to prevent sticky removal 214 | if ctx.input(|i| i.pointer.primary_released()) { 215 | *held = false; 216 | } 217 | 218 | let visuals = ctx.style().visuals.widgets.noninteractive; 219 | let mut update = false; 220 | 221 | toasts.retain_mut(|toast| { 222 | // Start disappearing expired toasts 223 | if let Some((_initial_d, current_d)) = toast.duration { 224 | if current_d <= 0. { 225 | toast.state = ToastState::Disappear; 226 | } 227 | } 228 | 229 | let anim_offset = toast.width * (1. - ease_in_cubic(toast.value)); 230 | pos.x += anim_offset * anchor.anim_side(); 231 | let rect = toast.calc_anchored_rect(pos, *anchor); 232 | 233 | if let Some((_, d)) = toast.duration.as_mut() { 234 | // Check if we hover over the toast and if true don't decrease the duration 235 | let hover_pos = ctx.input(|i| i.pointer.hover_pos()); 236 | let is_outside_rect = hover_pos.is_none_or(|pos| !rect.contains(pos)); 237 | 238 | if is_outside_rect && toast.state.idling() { 239 | *d -= ctx.input(|i| i.stable_dt); 240 | update = true; 241 | } 242 | } 243 | 244 | let caption_galley = toast.caption.clone().into_galley_impl( 245 | ctx, 246 | ctx.style().as_ref(), 247 | TextWrapping::from_wrap_mode_and_width(TextWrapMode::Extend, f32::INFINITY), 248 | FontSelection::Default, 249 | Align::LEFT, 250 | ); 251 | 252 | let (caption_width, caption_height) = 253 | (caption_galley.rect.width(), caption_galley.rect.height()); 254 | 255 | let line_count = toast.caption.text().chars().filter(|c| *c == '\n').count() + 1; 256 | let icon_width = caption_height / line_count as f32; 257 | let rounding = CornerRadius::same(4); 258 | 259 | // Create toast icon 260 | let icon_font = FontId::proportional(icon_width); 261 | let icon_galley = 262 | match &toast.level { 263 | ToastLevel::Info => { 264 | Some(ctx.fonts_mut(|f| { 265 | f.layout("ℹ".into(), icon_font, INFO_COLOR, f32::INFINITY) 266 | })) 267 | } 268 | ToastLevel::Warning => Some(ctx.fonts_mut(|f| { 269 | f.layout("⚠".into(), icon_font, WARNING_COLOR, f32::INFINITY) 270 | })), 271 | ToastLevel::Error => Some(ctx.fonts_mut(|f| { 272 | f.layout("!".into(), icon_font, ERROR_COLOR, f32::INFINITY) 273 | })), 274 | ToastLevel::Success => Some(ctx.fonts_mut(|f| { 275 | f.layout("✅".into(), icon_font, SUCCESS_COLOR, f32::INFINITY) 276 | })), 277 | ToastLevel::Custom(s, c) => { 278 | Some(ctx.fonts_mut(|f| f.layout(s.clone(), icon_font, *c, f32::INFINITY))) 279 | } 280 | ToastLevel::None => None, 281 | }; 282 | 283 | let (action_width, action_height) = 284 | icon_galley.as_ref().map_or((0., 0.), |icon_galley| { 285 | (icon_galley.rect.width(), icon_galley.rect.height()) 286 | }); 287 | 288 | // Create closing cross 289 | let cross_galley = if toast.closable { 290 | let cross_fid = FontId::proportional(icon_width); 291 | let cross_galley = ctx.fonts_mut(|f| { 292 | f.layout( 293 | "❌".into(), 294 | cross_fid, 295 | visuals.fg_stroke.color, 296 | f32::INFINITY, 297 | ) 298 | }); 299 | Some(cross_galley) 300 | } else { 301 | None 302 | }; 303 | 304 | let (cross_width, cross_height) = 305 | cross_galley.as_ref().map_or((0., 0.), |cross_galley| { 306 | (cross_galley.rect.width(), cross_galley.rect.height()) 307 | }); 308 | 309 | let icon_x_padding = (0., padding.x); 310 | let cross_x_padding = (padding.x, 0.); 311 | 312 | let icon_width_padded = if icon_width == 0. { 313 | 0. 314 | } else { 315 | icon_width + icon_x_padding.0 + icon_x_padding.1 316 | }; 317 | let cross_width_padded = if cross_width == 0. { 318 | 0. 319 | } else { 320 | cross_width + cross_x_padding.0 + cross_x_padding.1 321 | }; 322 | 323 | toast.width = padding 324 | .x 325 | .mul_add(2., icon_width_padded + caption_width + cross_width_padded); 326 | toast.height = padding 327 | .y 328 | .mul_add(2., action_height.max(caption_height).max(cross_height)); 329 | 330 | // Required due to positioning of the next toast 331 | pos.x -= anim_offset * anchor.anim_side(); 332 | 333 | // Draw shadow 334 | if let Some(shadow) = self.shadow { 335 | let s = shadow.as_shape(rect, rounding); 336 | p.add(s); 337 | } 338 | 339 | // Draw background 340 | p.rect_filled(rect, rounding, visuals.bg_fill); 341 | 342 | // Paint icon 343 | if let Some((icon_galley, true)) = 344 | icon_galley.zip(Some(toast.level != ToastLevel::None)) 345 | { 346 | let oy = toast.height / 2. - action_height / 2.; 347 | let ox = padding.x + icon_x_padding.0; 348 | p.galley( 349 | rect.min + vec2(ox, oy), 350 | icon_galley, 351 | visuals.fg_stroke.color, 352 | ); 353 | } 354 | 355 | // Paint caption 356 | let oy = toast.height / 2. - caption_height / 2.; 357 | let o_from_icon = if action_width == 0. { 358 | 0. 359 | } else { 360 | action_width + icon_x_padding.1 361 | }; 362 | let o_from_cross = if cross_width == 0. { 363 | 0. 364 | } else { 365 | cross_width + cross_x_padding.0 366 | }; 367 | let ox = (toast.width / 2. - caption_width / 2.) + o_from_icon / 2. - o_from_cross / 2.; 368 | p.galley( 369 | rect.min + vec2(ox, oy), 370 | caption_galley, 371 | visuals.fg_stroke.color, 372 | ); 373 | 374 | // Paint cross 375 | if let Some(cross_galley) = cross_galley { 376 | let cross_rect = cross_galley.rect; 377 | let oy = toast.height / 2. - cross_height / 2.; 378 | let ox = toast.width - cross_width - cross_x_padding.1 - padding.x; 379 | let cross_pos = rect.min + vec2(ox, oy); 380 | p.galley(cross_pos, cross_galley, Color32::BLACK); 381 | 382 | let screen_cross = Rect { 383 | max: cross_pos + cross_rect.max.to_vec2(), 384 | min: cross_pos, 385 | }; 386 | 387 | if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) { 388 | if screen_cross.contains(pos) && !*held { 389 | toast.dismiss(); 390 | *held = true; 391 | } 392 | } 393 | } 394 | 395 | // Draw duration 396 | if toast.show_progress_bar { 397 | if let Some((initial, current)) = toast.duration { 398 | if !toast.state.disappearing() { 399 | p.line_segment( 400 | [ 401 | rect.min + vec2(0., toast.height), 402 | rect.max - vec2((1. - (current / initial)) * toast.width, 0.), 403 | ], 404 | Stroke::new(4., visuals.fg_stroke.color), 405 | ); 406 | } 407 | } 408 | } 409 | 410 | toast.adjust_next_pos(&mut pos, *anchor, *spacing); 411 | 412 | // Animations 413 | if toast.state.appearing() { 414 | update = true; 415 | toast.value += ctx.input(|i| i.stable_dt) * (*speed); 416 | 417 | if toast.value >= 1. { 418 | toast.value = 1.; 419 | toast.state = ToastState::Idle; 420 | } 421 | } else if toast.state.disappearing() { 422 | update = true; 423 | toast.value -= ctx.input(|i| i.stable_dt) * (*speed); 424 | 425 | if toast.value <= 0. { 426 | toast.state = ToastState::Disappeared; 427 | } 428 | } 429 | 430 | // Remove disappeared toasts 431 | !toast.state.disappeared() 432 | }); 433 | 434 | if update { 435 | ctx.request_repaint(); 436 | } 437 | } 438 | } 439 | 440 | impl Default for Toasts { 441 | fn default() -> Self { 442 | Self::new() 443 | } 444 | } 445 | 446 | fn ease_in_cubic(x: f32) -> f32 { 447 | 1. - (1. - x).powi(3) 448 | } 449 | --------------------------------------------------------------------------------