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}