Skip to main content

egui_async/egui/widgets/
async_button.rs

1//! An intelligent action button that disables itself and displays an inline spinner while loading.
2
3use std::sync::Arc;
4
5use crate::bind::{Bind, MaybeSend};
6
7/// Contains the exact layout metrics needed to render the button without shifting.
8struct ButtonLayout {
9    normal_galley: Arc<egui::Galley>,
10    pending_galley: Arc<egui::Galley>,
11    desired_size: egui::Vec2,
12    spinner_size: f32,
13    item_spacing: egui::Vec2,
14}
15
16/// A button that initiates an asynchronous operation when clicked.
17///
18/// It automatically disables itself and swaps its display text to a spinner
19/// to prevent double-submissions and indicate work is being done.
20#[must_use = "You should call .show() on this widget to render it"]
21pub struct AsyncButton<'a, T, E> {
22    bind: &'a mut Bind<T, E>,
23    text: egui::WidgetText,
24    pending_text: Option<egui::WidgetText>,
25    frame: bool,
26    clear_on_click: bool,
27}
28
29impl<'a, T, E> AsyncButton<'a, T, E> {
30    /// Creates a new `AsyncButton`.
31    pub fn new(bind: &'a mut Bind<T, E>, text: impl Into<egui::WidgetText>) -> Self {
32        Self {
33            bind,
34            text: text.into(),
35            pending_text: None,
36            frame: true,
37            clear_on_click: true,
38        }
39    }
40
41    /// Sets the text to display next to the spinner while the operation is pending.
42    pub fn pending_text(mut self, text: impl Into<egui::WidgetText>) -> Self {
43        self.pending_text = Some(text.into());
44        self
45    }
46
47    /// If set to `false`, the button will be rendered as plain text without a background frame,
48    /// similar to a clickable hyperlink label.
49    pub const fn frame(mut self, frame: bool) -> Self {
50        self.frame = frame;
51        self
52    }
53
54    /// If set to `true` (default), the button will immediately clear the `Bind`'s previous
55    /// data upon being clicked. If `false`, the old data will remain accessible via `.read()`
56    /// while the new fetch is pending.
57    pub const fn clear_on_click(mut self, clear: bool) -> Self {
58        self.clear_on_click = clear;
59        self
60    }
61
62    /// Shows the button in the given UI and triggers the future if clicked.
63    pub fn show<Fut>(self, ui: &mut egui::Ui, f: impl FnOnce() -> Fut) -> egui::Response
64    where
65        Fut: Future<Output = Result<T, E>> + MaybeSend + 'static,
66        T: MaybeSend + 'static,
67        E: MaybeSend + 'static,
68    {
69        let is_pending = self.bind.is_pending();
70        let layout = self.calculate_layout(ui);
71
72        let sense = if is_pending {
73            egui::Sense::hover()
74        } else {
75            egui::Sense::click()
76        };
77
78        let (rect, resp) = ui.allocate_exact_size(layout.desired_size, sense);
79
80        if ui.is_rect_visible(rect) {
81            self.paint_visuals(ui, &rect, &resp, &layout, is_pending);
82        }
83
84        if resp.clicked() && !is_pending {
85            if self.clear_on_click {
86                self.bind.refresh(f());
87            } else {
88                self.bind.request(f());
89            }
90        }
91
92        if is_pending {
93            resp.on_hover_cursor(egui::CursorIcon::Wait)
94        } else {
95            resp.on_hover_cursor(egui::CursorIcon::PointingHand)
96        }
97    }
98
99    /// Calculates galleys and maximum necessary bounding boxes to prevent visual shift.
100    fn calculate_layout(&self, ui: &egui::Ui) -> ButtonLayout {
101        let pending_display = self
102            .pending_text
103            .clone()
104            .unwrap_or_else(|| self.text.clone());
105
106        let normal_galley =
107            self.text
108                .clone()
109                .into_galley(ui, None, f32::INFINITY, egui::FontSelection::Default);
110        let pending_galley =
111            pending_display.into_galley(ui, None, f32::INFINITY, egui::FontSelection::Default);
112
113        let button_padding = if self.frame {
114            ui.spacing().button_padding
115        } else {
116            egui::vec2(2.0, 2.0)
117        };
118
119        let item_spacing = ui.spacing().item_spacing;
120        let spinner_size = ui.text_style_height(&egui::TextStyle::Button);
121
122        let normal_size = normal_galley.size() + 2.0 * button_padding;
123        let mut pending_size = pending_galley.size() + 2.0 * button_padding;
124
125        if pending_galley.size().x > 0.0 {
126            pending_size.x += item_spacing.x;
127        }
128
129        pending_size.x += spinner_size;
130        pending_size.y = pending_size
131            .y
132            .max(2.0f32.mul_add(button_padding.y, spinner_size));
133
134        let desired_size = egui::vec2(
135            normal_size.x.max(pending_size.x),
136            normal_size.y.max(pending_size.y),
137        );
138
139        ButtonLayout {
140            normal_galley,
141            pending_galley,
142            desired_size,
143            spinner_size,
144            item_spacing,
145        }
146    }
147
148    /// Handles painting the background, strokes, spinner, and centered text.
149    fn paint_visuals(
150        &self,
151        ui: &mut egui::Ui,
152        rect: &egui::Rect,
153        resp: &egui::Response,
154        layout: &ButtonLayout,
155        is_pending: bool,
156    ) {
157        let visuals = if is_pending {
158            ui.style().visuals.widgets.noninteractive
159        } else {
160            *ui.style().interact(resp)
161        };
162
163        // 1. Paint the background
164        if self.frame || (resp.hovered() && !is_pending) {
165            let (fill, stroke) = if is_pending && self.frame {
166                (
167                    ui.style().visuals.widgets.inactive.bg_fill,
168                    ui.style().visuals.widgets.inactive.bg_stroke,
169                )
170            } else if self.frame || resp.hovered() {
171                (visuals.bg_fill, visuals.bg_stroke)
172            } else {
173                (egui::Color32::TRANSPARENT, egui::Stroke::NONE)
174            };
175
176            let expansion = if resp.hovered() && !is_pending && self.frame {
177                visuals.expansion
178            } else if !self.frame && resp.hovered() && !is_pending {
179                ui.spacing().item_spacing.x * 0.5
180            } else {
181                0.0
182            };
183
184            ui.painter().rect(
185                rect.expand(expansion),
186                visuals.corner_radius,
187                fill,
188                stroke,
189                egui::StrokeKind::Middle,
190            );
191        }
192
193        // 2. Center content inside the fixed rect
194        let current_galley = if is_pending {
195            &layout.pending_galley
196        } else {
197            &layout.normal_galley
198        };
199
200        let content_width = if is_pending {
201            if current_galley.size().x > 0.0 {
202                current_galley.size().x + layout.item_spacing.x + layout.spinner_size
203            } else {
204                layout.spinner_size
205            }
206        } else {
207            current_galley.size().x
208        };
209
210        let mut cursor_x = rect.center().x - content_width / 2.0;
211
212        // 3. Paint the spinner (if pending)
213        if is_pending {
214            let spinner_rect = egui::Rect::from_min_size(
215                egui::pos2(cursor_x, rect.center().y - layout.spinner_size / 2.0),
216                egui::vec2(layout.spinner_size, layout.spinner_size),
217            );
218
219            ui.put(
220                spinner_rect,
221                egui::Spinner::new()
222                    .size(layout.spinner_size)
223                    .color(visuals.text_color()),
224            );
225
226            cursor_x += layout.spinner_size;
227            if current_galley.size().x > 0.0 {
228                cursor_x += layout.item_spacing.x;
229            }
230        }
231
232        // 4. Paint the text
233        if current_galley.size().x > 0.0 {
234            let text_color = visuals.text_color();
235            let text_pos = egui::pos2(cursor_x, rect.center().y - current_galley.size().y / 2.0);
236
237            ui.painter()
238                .galley(text_pos, current_galley.clone(), text_color);
239        }
240    }
241}