egui_async/
egui.rs

1//! `egui` integration for `egui-async`.
2//!
3//! This module provides the [`EguiAsyncPlugin`], which is necessary to integrate
4//! `egui-async` into an `egui` application.
5//!
6//! To use `egui-async`, you must register the plugin with your `egui::Context`.
7//! The simplest way is to call `ctx.plugin_or_default::<EguiAsyncPlugin>();`
8//! once per frame in your application's update loop.
9
10use std::fmt::Debug;
11
12use super::bind::{self, Bind, MaybeSend, State};
13
14/// The plugin that drives `egui-async`'s per-frame updates.
15///
16/// This plugin **must be registered** with `egui` for `egui-async` to work.
17/// It is responsible for updating frame timers and setting the global `egui::Context`
18/// so that background tasks can request repaints.
19///
20/// The easiest way to register it is to call `ctx.plugin_or_default::<EguiAsyncPlugin>();`
21/// in your `eframe::App::update` method or equivalent. `egui` ensures this is a
22/// cheap, idempotent operation.
23#[derive(Default)]
24pub struct EguiAsyncPlugin;
25
26impl egui::Plugin for EguiAsyncPlugin {
27    fn debug_name(&self) -> &'static str {
28        "egui_async"
29    }
30
31    fn on_begin_pass(&mut self, ctx: &egui::Context) {
32        // This logic was previously in `ContextExt::loop_handle`.
33        // It's responsible for updating the frame timers and ensuring the global
34        // context is set for background tasks to request repaints.
35        bind::CTX.get_or_init(|| ctx.clone());
36        let time = ctx.input(|i| i.time);
37
38        let last_frame = bind::CURR_FRAME.swap(time, std::sync::atomic::Ordering::Relaxed);
39        bind::LAST_FRAME.store(last_frame, std::sync::atomic::Ordering::Relaxed);
40    }
41}
42
43impl<T: 'static, E: Debug + 'static> Bind<T, E> {
44    /// Reads the data if available, otherwise shows an error popup if there was an error.
45    /// If there was an error, the popup will have a "Retry" button that will trigger the given future.
46    /// If the data is not available, returns `None`.
47    /// This does NOT automatically request the data if it is not available.
48    pub fn read_or_error<Fut>(&mut self, f: impl FnOnce() -> Fut, ui: &mut egui::Ui) -> Option<&T>
49    where
50        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
51        T: MaybeSend,
52        E: MaybeSend,
53    {
54        self.poll();
55
56        if let Some(Err(e)) = &self.data {
57            let error_string = format!("{e:?}");
58            if ui.popup_error(&error_string) {
59                self.request(f());
60            }
61            None
62        } else if let Some(Ok(data)) = self.data.as_ref() {
63            Some(data)
64        } else {
65            None
66        }
67    }
68
69    /// Reads the data mutably if available, otherwise shows an error popup if there was an error.
70    /// If there was an error, the popup will have a "Retry" button that will
71    /// trigger the given future.
72    /// If the data is not available, returns `None`.
73    /// This does NOT automatically request the data if it is not available.
74    pub fn read_mut_or_error<Fut>(
75        &mut self,
76        f: impl FnOnce() -> Fut,
77        ui: &mut egui::Ui,
78    ) -> Option<&mut T>
79    where
80        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
81        T: MaybeSend,
82        E: MaybeSend,
83    {
84        self.poll();
85
86        if let Some(Err(e)) = &self.data {
87            let error_string = format!("{e:?}");
88            if ui.popup_error(&error_string) {
89                self.request(f());
90            }
91            None
92        } else if let Some(Ok(data)) = self.data.as_mut() {
93            Some(data)
94        } else {
95            None
96        }
97    }
98
99    /// Reads the data if available, otherwise requests it using the given future.
100    /// If there was an error, the popup will have a "Retry" button that will
101    /// trigger the given future.
102    /// If the data is not available, returns `None`.
103    /// This automatically requests the data if it is not available.
104    pub fn read_or_request_or_error<Fut>(
105        &mut self,
106        f: impl FnOnce() -> Fut,
107        ui: &mut egui::Ui,
108    ) -> Option<&T>
109    where
110        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
111        T: MaybeSend,
112        E: MaybeSend,
113    {
114        self.poll();
115
116        if matches!(self.state, State::Idle) {
117            self.request(f());
118            None
119        } else if let Some(Err(e)) = &self.data {
120            let error_string = format!("{e:?}");
121            if ui.popup_error(&error_string) {
122                self.request(f());
123            }
124            None
125        } else if let Some(Ok(data)) = self.data.as_ref() {
126            Some(data)
127        } else {
128            None
129        }
130    }
131
132    /// Reads the data mutably if available, otherwise requests it using the given future.
133    /// If there was an error, the popup will have a "Retry" button that will
134    /// trigger the given future.
135    /// If the data is not available, returns `None`.
136    /// This automatically requests the data if it is not available.
137    pub fn read_mut_or_request_or_error<Fut>(
138        &mut self,
139        f: impl FnOnce() -> Fut,
140        ui: &mut egui::Ui,
141    ) -> Option<&mut T>
142    where
143        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
144        T: MaybeSend,
145        E: MaybeSend,
146    {
147        self.poll();
148
149        if matches!(self.state, State::Idle) {
150            self.request(f());
151            None
152        } else if let Some(Err(e)) = &self.data {
153            let error_string = format!("{e:?}");
154            if ui.popup_error(&error_string) {
155                self.request(f());
156            }
157            None
158        } else if let Some(Ok(data)) = self.data.as_mut() {
159            Some(data)
160        } else {
161            None
162        }
163    }
164}
165
166/// Extension trait for [`egui::Ui`] providing convenient UI widgets.
167pub trait UiExt {
168    /// Pops up an error window with the given error message.
169    ///
170    /// # Returns
171    /// `true` if the "Retry" button was clicked.
172    fn popup_error(&self, error: &str) -> bool;
173    /// Pops up a notification window with the given info message.
174    ///
175    /// # Returns
176    /// `true` if the "Ok" button was clicked.
177    fn popup_notify(&self, info: &str) -> bool;
178
179    /// Adds a refresh button that triggers a new future when clicked.
180    ///
181    /// This widget also handles periodic refreshing.
182    ///
183    /// The button shows a tooltip with the time remaining until the next automatic refresh.
184    /// If the button is clicked, it triggers an immediate refresh, unless the last refresh
185    /// was less than `secs / 4.0` seconds ago to prevent spamming.
186    fn refresh_button<T, E, Fut>(
187        &mut self,
188        bind: &mut bind::Bind<T, E>,
189        f: impl FnOnce() -> Fut,
190        secs: f64,
191    ) where
192        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
193        T: MaybeSend + 'static,
194        E: MaybeSend + 'static;
195}
196
197const REFRESH_DEBOUNCE_FACTOR: f64 = 4.0;
198
199impl UiExt for egui::Ui {
200    fn popup_error(&self, error: &str) -> bool {
201        let screen_rect = self.ctx().content_rect();
202        let total_width = screen_rect.width();
203        let total_height = screen_rect.height();
204
205        let id = egui::Id::new("error_window");
206        egui::Window::new("Error")
207            .id(id)
208            .collapsible(false)
209            .default_width(total_width * 0.25)
210            .default_height(total_height * 0.20)
211            .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
212            .show(self.ctx(), |ui| {
213                ui.vertical_centered(|ui| {
214                    ui.label(egui::RichText::new(error).color(egui::Color32::RED));
215
216                    ui.add_space(10.0);
217
218                    ui.label("Please retry the request, or contact support if the error persists.");
219
220                    ui.add_space(10.0);
221
222                    ui.button("Retry").clicked()
223                })
224                .inner
225            })
226            .is_some_and(|r| r.inner.is_some_and(|r| r))
227    }
228    fn popup_notify(&self, info: &str) -> bool {
229        let screen_rect = self.ctx().content_rect();
230        let total_width = screen_rect.width();
231        let total_height = screen_rect.height();
232
233        let id = egui::Id::new("notify_window");
234        egui::Window::new("Info")
235            .id(id)
236            .collapsible(false)
237            .default_width(total_width * 0.25)
238            .default_height(total_height * 0.20)
239            .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
240            .show(self.ctx(), |ui| {
241                ui.vertical_centered(|ui| {
242                    ui.label(info);
243
244                    ui.add_space(10.0);
245
246                    ui.button("Ok").clicked()
247                })
248                .inner
249            })
250            .is_some_and(|r| r.inner.is_some_and(|r| r))
251    }
252
253    fn refresh_button<T, E, Fut>(
254        &mut self,
255        bind: &mut bind::Bind<T, E>,
256        f: impl FnOnce() -> Fut,
257        secs: f64,
258    ) where
259        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
260        T: MaybeSend + 'static,
261        E: MaybeSend + 'static,
262    {
263        let resp = self.button("🔄");
264
265        // Only actually refresh when clicked if the last completion was more than 1/4 of the interval ago
266        let diff = if bind.since_completed() > secs / REFRESH_DEBOUNCE_FACTOR && resp.clicked() {
267            bind.refresh(f());
268            -1.0
269        } else {
270            bind.request_every_sec(f, secs)
271        };
272
273        resp.on_hover_text(if diff < 0.0 {
274            "Refreshing now!".to_string()
275        } else {
276            format!("Refreshing automatically in {diff:.0}s...")
277        });
278    }
279}