egui_modal_spinner/
lib.rs

1//! This crate implements a modal spinner for [egui](https://github.com/emilk/egui) to suppress user input. \
2//! This is useful, for example, when performing resource-intensive tasks that do
3//! not require the user to interact with the application.
4//!
5//! # Example
6//! See [sandbox](https://github.com/fluxxcode/egui-modal-spinner/tree/master/examples/sandbox) for the full example.
7//!
8//! The following example shows the basic use of the spinner with [eframe](https://github.com/emilk/egui/tree/master/crates/eframe).
9//!
10//! Cargo.toml:
11//! ```toml
12//! [dependencies]
13//! eframe = "0.29"
14//! egui-modal-spinner = "0.1.0"
15//! ```
16//!
17//! main.rs:
18//! ```rust
19//! use std::sync::mpsc;
20//! use std::thread;
21//!
22//! use egui_modal_spinner::ModalSpinner;
23//!
24//! struct MyApp {
25//!     spinner: ModalSpinner,
26//!     result_recv: Option<mpsc::Receiver<bool>>,
27//! }
28//!
29//! impl MyApp {
30//!     pub fn new() -> Self {
31//!         Self {
32//!             /// >>> Create a spinner instance
33//!             spinner: ModalSpinner::new(),
34//!             result_recv: None,
35//!         }
36//!     }
37//!
38//!     pub fn update(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
39//!         if ui.button("Download some data").clicked() {
40//!             // Create a new thread to execute the task
41//!             let (tx, rx) = mpsc::channel();
42//!             self.result_recv = Some(rx);
43//!
44//!             thread::spawn(move || {
45//!                 // Do some heavy resource task
46//!                 thread::sleep(std::time::Duration::from_secs(5));
47//!
48//!                 // Send some thread status to the receiver
49//!                 let _ = tx.send(true);
50//!             });
51//!
52//!             // >>> Open the spinner
53//!             self.spinner.open();
54//!         }
55//!
56//!         if let Some(rx) = &self.result_recv {
57//!             if let Ok(_) = rx.try_recv() {
58//!                 // >>> Close the spinner when the thread finishes executing the task
59//!                 self.spinner.close()
60//!             }
61//!         }
62//!
63//!         // >>> Update the spinner
64//!         self.spinner.update(ctx);
65//!
66//!         // Alternatively, you can also display your own UI below the spinner.
67//!         // This is useful when you want to display the status of the currently running task.
68//!         self.spinner.update_with_content(ctx, |ui| {
69//!             ui.label("Downloading some data...");
70//!         })
71//!     }
72//! }
73//! ```
74//!
75//! # Configuration
76//! The following example shows the possible configuration options.
77//! ```rust
78//! use egui_modal_spinner::ModalSpinner;
79//!
80//! let spinner = ModalSpinner::new()
81//!     .id("My custom spinner")
82//!     .fill_color(egui::Color32::BLUE)
83//!     .fade_in(false)
84//!     .fade_out(true)
85//!     .spinner_size(40.0)
86//!     .spinner_color(egui::Color32::RED)
87//!     .show_elapsed_time(false);
88//! ```
89
90#![warn(missing_docs)] // Let's keep the public API well documented!
91
92use std::time::SystemTime;
93
94use egui::Widget;
95
96/// Represents the state the spinner is currently in.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum SpinnerState {
99    /// The spinner is currently closed and not visible.
100    Closed,
101    /// The spinner is currently open and user input is suppressed.
102    Open,
103}
104
105/// Represents a spinner instance.
106#[derive(Debug, Clone)]
107pub struct ModalSpinner {
108    /// Represents the state of the spinner.
109    state: SpinnerState,
110    /// If the modal is closed but currently fading out.
111    fading_out: bool,
112    /// Timestamp when the spinner was opened.
113    timestamp: SystemTime,
114
115    /// The ID of the modal area. If None, a default is used.
116    id: Option<egui::Id>,
117    /// The fill color of the modal background.
118    fill_color: Option<egui::Color32>,
119    /// If the modal window should fade in when opening.
120    fade_in: bool,
121    /// If the modal should fade out when closing.
122    fade_out: bool,
123    /// Configuration of the spinner.
124    spinner: Spinner,
125    /// If the time elapsed since opening should be displayed under the spinner.
126    show_elapsed_time: bool,
127}
128
129impl Default for ModalSpinner {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// Creation methods
136impl ModalSpinner {
137    /// Creates a new spinner instance.
138    pub fn new() -> Self {
139        Self {
140            state: SpinnerState::Closed,
141            fading_out: false,
142            timestamp: SystemTime::now(),
143
144            id: None,
145            fill_color: None,
146            fade_in: true,
147            fade_out: true,
148            spinner: Spinner::default(),
149            show_elapsed_time: true,
150        }
151    }
152
153    /// Sets the ID of the spinner.
154    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
155        self.id = Some(id.into());
156        self
157    }
158
159    /// Sets the fill color of the modal background.
160    pub fn fill_color(mut self, color: impl Into<egui::Color32>) -> Self {
161        self.fill_color = Some(color.into());
162        self
163    }
164
165    /// If the modal should fade in.
166    pub const fn fade_in(mut self, fade_in: bool) -> Self {
167        self.fade_in = fade_in;
168        self
169    }
170
171    /// If the modal should fade out.
172    pub const fn fade_out(mut self, fade_out: bool) -> Self {
173        self.fade_out = fade_out;
174        self
175    }
176
177    /// Sets the size of the spinner.
178    pub const fn spinner_size(mut self, size: f32) -> Self {
179        self.spinner.size = Some(size);
180        self
181    }
182
183    /// Sets the color of the spinner.
184    pub fn spinner_color(mut self, color: impl Into<egui::Color32>) -> Self {
185        self.spinner.color = Some(color.into());
186        self
187    }
188
189    /// If the elapsed time should be displayed below the spinner.
190    pub const fn show_elapsed_time(mut self, show_elapsed_time: bool) -> Self {
191        self.show_elapsed_time = show_elapsed_time;
192        self
193    }
194}
195
196/// Getter and setter
197impl ModalSpinner {
198    /// Gets the current state of the spinner.
199    pub const fn state(&self) -> &SpinnerState {
200        &self.state
201    }
202}
203
204/// Implementation methods
205impl ModalSpinner {
206    /// Opens the spinner.
207    pub fn open(&mut self) {
208        self.state = SpinnerState::Open;
209        self.timestamp = SystemTime::now();
210    }
211
212    /// Closes the spinner.
213    #[allow(clippy::missing_const_for_fn)]
214    pub fn close(&mut self) {
215        self.state = SpinnerState::Closed;
216        self.fading_out = self.fade_out;
217    }
218
219    /// Main update method of the spinner that should be called every frame if you want the
220    /// spinner to be visible.
221    ///
222    /// This has no effect if the `SpinnerState` is currently not `SpinnerState::Open`.
223    pub fn update(&mut self, ctx: &egui::Context) {
224        self.update_ui(ctx, |_| ());
225    }
226
227    /// Main update method of the spinner that should be called every frame if you want the
228    /// spinner to be visible.
229    ///
230    /// This method allows additional content to be displayed under the
231    /// spinner - or if activated - under the elapsed time.
232    /// However, note that the additional content is not taken into account when
233    /// centering the spinner. Therefore, a large amount of additional
234    /// content on the Y-axis is not recommended.
235    ///
236    /// This has no effect if the `SpinnerState` is currently not `SpinnerState::Open`.
237    pub fn update_with_content(&mut self, ctx: &egui::Context, ui: impl FnOnce(&mut egui::Ui)) {
238        self.update_ui(ctx, ui);
239    }
240}
241
242/// UI methods
243impl ModalSpinner {
244    fn update_ui(&mut self, ctx: &egui::Context, content: impl FnOnce(&mut egui::Ui)) {
245        if self.state != SpinnerState::Open && !self.fading_out {
246            return;
247        }
248
249        let id = self.id.unwrap_or_else(|| egui::Id::from("_modal_spinner"));
250        let content_rect = ctx.input(egui::InputState::content_rect);
251
252        let opacity = ctx.animate_bool_with_easing(
253            id.with("fade_out"),
254            self.state == SpinnerState::Open,
255            egui::emath::easing::cubic_out,
256        );
257
258        if opacity <= 0.0 && self.fading_out {
259            self.fading_out = false;
260            return;
261        }
262
263        let re = egui::Area::new(id)
264            .movable(false)
265            .interactable(true)
266            .fixed_pos(content_rect.left_top())
267            .fade_in(self.fade_in)
268            .show(ctx, |ui| {
269                if self.fading_out {
270                    ui.multiply_opacity(opacity);
271                }
272
273                let fill_color = self.fill_color.unwrap_or_else(|| {
274                    if ctx.style().visuals.dark_mode {
275                        egui::Color32::from_black_alpha(120)
276                    } else {
277                        egui::Color32::from_white_alpha(40)
278                    }
279                });
280
281                ui.painter()
282                    .rect_filled(content_rect, egui::CornerRadius::ZERO, fill_color);
283
284                ui.allocate_response(content_rect.size(), egui::Sense::click());
285
286                let child_ui = egui::UiBuilder::new()
287                    .max_rect(content_rect)
288                    .layout(egui::Layout::top_down(egui::Align::Center));
289
290                ui.scope_builder(child_ui, |ui| {
291                    self.ui_update_spinner(ui, &content_rect);
292                    content(ui);
293                });
294            });
295
296        ctx.move_to_top(re.response.layer_id);
297    }
298
299    fn ui_update_spinner(&self, ui: &mut egui::Ui, screen_rect: &egui::Rect) {
300        let spinner_h = self
301            .spinner
302            .size
303            .unwrap_or_else(|| ui.style().spacing.interact_size.y);
304
305        let mut margin = screen_rect.height() / 2.0 - spinner_h / 2.0;
306
307        if self.show_elapsed_time {
308            let height = ui.fonts_mut(|f| f.row_height(&egui::TextStyle::Body.resolve(ui.style())));
309            margin -= ui.spacing().item_spacing.y.mul_add(2.0, height / 2.0);
310        }
311
312        ui.add_space(margin);
313
314        self.spinner.update(ui);
315
316        if self.show_elapsed_time {
317            self.ui_update_elapsed_time(ui);
318        }
319    }
320
321    fn ui_update_elapsed_time(&self, ui: &mut egui::Ui) {
322        ui.add_space(ui.spacing().item_spacing.y);
323        ui.label(format!(
324            "Elapsed: {} s",
325            self.timestamp.elapsed().unwrap_or_default().as_secs()
326        ));
327    }
328}
329
330/// This tests if the spinner is send and sync.
331#[cfg(test)]
332const fn test_prop<T: Send + Sync>() {}
333
334#[test]
335const fn test() {
336    test_prop::<ModalSpinner>();
337}
338
339/// Wrapper above `egui::Spinner` to be able to customize trait implementations.
340#[derive(Debug, Default, Clone, PartialEq)]
341struct Spinner {
342    pub size: Option<f32>,
343    pub color: Option<egui::Color32>,
344}
345
346impl Spinner {
347    fn update(&self, ui: &mut egui::Ui) -> egui::Response {
348        let mut spinner = egui::Spinner::new();
349
350        if let Some(size) = self.size {
351            spinner = spinner.size(size);
352        }
353
354        if let Some(color) = self.color {
355            spinner = spinner.color(color);
356        }
357
358        spinner.ui(ui)
359    }
360}