Skip to main content

egui_material3/
actionsheet.rs

1use crate::theme::get_global_color;
2use egui::{
3    ecolor::Color32,
4    epaint::{CornerRadius, Stroke},
5    pos2, Area, Id, Order, Rect, Response, Sense, Ui, Vec2,
6};
7
8/// Material Design action sheet component.
9///
10/// Action sheets present a set of actions to the user in a slide-up panel.
11/// They appear from the bottom of the screen and can contain multiple groups of actions.
12///
13/// # Example
14/// ```rust
15/// # egui::__run_test_ui(|ui| {
16/// let mut sheet_open = false;
17///
18/// let sheet = MaterialActionSheet::new("my_sheet", &mut sheet_open)
19///     .label("Do something")
20///     .button("Button 1", || {
21///         println!("Button 1 clicked");
22///     })
23///     .button("Button 2", || {
24///         println!("Button 2 clicked");
25///     })
26///     .button("Cancel", || {
27///         println!("Cancel clicked");
28///     });
29///
30/// sheet.show(ui.ctx());
31/// # });
32/// ```
33#[must_use = "You should call .show() to display the action sheet"]
34pub struct MaterialActionSheet<'a> {
35    /// Unique identifier for this action sheet
36    id: Id,
37    /// Reference to open/closed state
38    open: &'a mut bool,
39    /// Whether to show backdrop/scrim
40    backdrop: bool,
41    /// Whether tapping backdrop closes the sheet
42    backdrop_dismissible: bool,
43    /// Groups of action buttons
44    groups: Vec<ActionGroup<'a>>,
45    /// Current group being built
46    current_group: ActionGroup<'a>,
47    /// Maximum width of the sheet
48    max_width: f32,
49}
50
51/// A group of action buttons with an optional label
52pub struct ActionGroup<'a> {
53    /// Optional label for this group
54    pub label: Option<String>,
55    /// Buttons in this group
56    pub buttons: Vec<ActionButton<'a>>,
57}
58
59/// An individual action button in the sheet
60pub struct ActionButton<'a> {
61    /// Button text
62    pub text: String,
63    /// Whether text should be bold
64    pub bold: bool,
65    /// Optional callback
66    pub on_click: Option<Box<dyn FnOnce() + 'a>>,
67    /// Whether button is enabled
68    pub enabled: bool,
69}
70
71impl<'a> MaterialActionSheet<'a> {
72    /// Create a new action sheet
73    ///
74    /// # Arguments
75    /// * `id` - Unique identifier for this action sheet
76    /// * `open` - Mutable reference to the open/closed state
77    pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
78        Self {
79            id: id.into(),
80            open,
81            backdrop: true,
82            backdrop_dismissible: true,
83            groups: Vec::new(),
84            current_group: ActionGroup {
85                label: None,
86                buttons: Vec::new(),
87            },
88            max_width: 448.0, // Material Design 3 max width for mobile
89        }
90    }
91
92    /// Set whether to show backdrop (default: true)
93    pub fn backdrop(mut self, show: bool) -> Self {
94        self.backdrop = show;
95        self
96    }
97
98    /// Set whether tapping backdrop dismisses the sheet (default: true)
99    pub fn backdrop_dismissible(mut self, dismissible: bool) -> Self {
100        self.backdrop_dismissible = dismissible;
101        self
102    }
103
104    /// Set the maximum width of the action sheet
105    pub fn max_width(mut self, width: f32) -> Self {
106        self.max_width = width;
107        self
108    }
109
110    /// Add a label for the current group
111    pub fn label(mut self, text: impl Into<String>) -> Self {
112        self.current_group.label = Some(text.into());
113        self
114    }
115
116    /// Add an action button to the current group
117    pub fn button<F>(mut self, text: impl Into<String>, callback: F) -> Self
118    where
119        F: FnOnce() + 'a,
120    {
121        self.current_group.buttons.push(ActionButton {
122            text: text.into(),
123            bold: false,
124            on_click: Some(Box::new(callback)),
125            enabled: true,
126        });
127        self
128    }
129
130    /// Add a bold action button to the current group
131    pub fn bold_button<F>(mut self, text: impl Into<String>, callback: F) -> Self
132    where
133        F: FnOnce() + 'a,
134    {
135        self.current_group.buttons.push(ActionButton {
136            text: text.into(),
137            bold: true,
138            on_click: Some(Box::new(callback)),
139            enabled: true,
140        });
141        self
142    }
143
144    /// Add a button without callback
145    pub fn simple_button(mut self, text: impl Into<String>) -> Self {
146        self.current_group.buttons.push(ActionButton {
147            text: text.into(),
148            bold: false,
149            on_click: None,
150            enabled: true,
151        });
152        self
153    }
154
155    /// Start a new group (finalizes the current group)
156    pub fn new_group(mut self) -> Self {
157        if !self.current_group.buttons.is_empty() || self.current_group.label.is_some() {
158            let group = std::mem::replace(
159                &mut self.current_group,
160                ActionGroup {
161                    label: None,
162                    buttons: Vec::new(),
163                },
164            );
165            self.groups.push(group);
166        }
167        self
168    }
169
170    /// Show the action sheet
171    pub fn show(mut self, ctx: &egui::Context) -> Response {
172        // Finalize any remaining group
173        if !self.current_group.buttons.is_empty() || self.current_group.label.is_some() {
174            let group = std::mem::replace(
175                &mut self.current_group,
176                ActionGroup {
177                    label: None,
178                    buttons: Vec::new(),
179                },
180            );
181            self.groups.push(group);
182        }
183
184        if !*self.open {
185            // Return empty response when closed
186            return Area::new(self.id.with("dummy"))
187                .fixed_pos(pos2(-1000.0, -1000.0))
188                .show(ctx, |ui| ui.allocate_response(Vec2::ZERO, Sense::hover()))
189                .response;
190        }
191
192        // Handle ESC key
193        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
194            *self.open = false;
195        }
196
197        let screen_rect = ctx.viewport_rect();
198        let backdrop_dismissible = self.backdrop_dismissible;
199
200        // Draw backdrop/scrim
201        if self.backdrop {
202            let scrim_color = Color32::from_rgba_unmultiplied(0, 0, 0, 128);
203
204            Area::new(self.id.with("backdrop"))
205                .order(Order::Middle)
206                .fixed_pos(pos2(0.0, 0.0))
207                .show(ctx, |ui| {
208                    let scrim_response = ui.allocate_response(screen_rect.size(), Sense::click());
209                    ui.painter()
210                        .rect_filled(screen_rect, CornerRadius::ZERO, scrim_color);
211
212                    if scrim_response.clicked() && backdrop_dismissible {
213                        *self.open = false;
214                    }
215                });
216        }
217
218        // Draw action sheet
219        let open_ptr = self.open as *mut bool;
220        Area::new(self.id.with("sheet"))
221            .order(Order::Foreground)
222            .anchor(egui::Align2::CENTER_BOTTOM, Vec2::new(0.0, 0.0))
223            .show(ctx, |ui| {
224                let width = self.max_width.min(screen_rect.width());
225                ui.set_width(width);
226
227                // SAFETY: We're only using this pointer within this closure
228                // and it doesn't outlive the mutable borrow
229                unsafe { self.render_content(ui, &mut *open_ptr) }
230            })
231            .response
232    }
233
234    fn render_content(self, ui: &mut Ui, open: &'a mut bool) -> Response {
235        let background_color = get_global_color("surfaceContainer");
236        let corner_radius = CornerRadius {
237            nw: 16,
238            ne: 16,
239            sw: 0,
240            se: 0,
241        };
242
243        let mut total_height = 0.0;
244        let button_height = 48.0; // Material Design 3 touch target
245
246        // Calculate total height
247        for group in &self.groups {
248            if group.label.is_some() {
249                total_height += button_height;
250            }
251            total_height += group.buttons.len() as f32 * button_height;
252        }
253
254        // Add safe area padding at bottom
255        total_height += 8.0;
256
257        let available_rect = ui.available_rect_before_wrap();
258        let sheet_size = Vec2::new(available_rect.width(), total_height);
259        let sheet_rect = Rect::from_min_size(
260            pos2(available_rect.min.x, available_rect.max.y - total_height),
261            sheet_size,
262        );
263
264        // Draw background
265        ui.painter()
266            .rect_filled(sheet_rect, corner_radius, background_color);
267
268        let mut current_y = sheet_rect.min.y;
269        let mut response = ui.allocate_response(sheet_size, Sense::hover());
270
271        // Collect callbacks to execute after rendering
272        let mut callbacks_to_execute: Vec<Box<dyn FnOnce()>> = Vec::new();
273
274        // Store groups length before moving
275        let groups_len = self.groups.len();
276
277        // Render groups
278        for (group_idx, group) in self.groups.into_iter().enumerate() {
279            // Draw label if present
280            if let Some(label) = &group.label {
281                let label_rect = Rect::from_min_size(
282                    pos2(sheet_rect.min.x, current_y),
283                    Vec2::new(sheet_rect.width(), button_height),
284                );
285
286                let text_color = get_global_color("primary");
287                let text_pos = pos2(label_rect.min.x + 16.0, current_y + button_height / 2.0);
288
289                ui.painter().text(
290                    text_pos,
291                    egui::Align2::LEFT_CENTER,
292                    label,
293                    egui::FontId::proportional(14.0),
294                    text_color,
295                );
296
297                current_y += button_height;
298            }
299
300            // Draw buttons
301            for (button_idx, mut button) in group.buttons.into_iter().enumerate() {
302                let button_id = self.id.with("group").with(group_idx).with(button_idx);
303                let button_rect = Rect::from_min_size(
304                    pos2(sheet_rect.min.x, current_y),
305                    Vec2::new(sheet_rect.width(), button_height),
306                );
307
308                let button_response = ui.interact(button_rect, button_id, Sense::click());
309
310                // Hover effect
311                if button_response.hovered() && button.enabled {
312                    let hover_color = get_global_color("onSurface").linear_multiply(0.08);
313                    ui.painter()
314                        .rect_filled(button_rect, CornerRadius::ZERO, hover_color);
315                }
316
317                // Draw button text
318                let text_color = if !button.enabled {
319                    get_global_color("onSurface").linear_multiply(0.38)
320                } else {
321                    get_global_color("onSurface")
322                };
323
324                let font_id = if button.bold {
325                    egui::FontId::proportional(16.0)
326                } else {
327                    egui::FontId::proportional(16.0)
328                };
329
330                let text_pos = pos2(button_rect.min.x + 16.0, current_y + button_height / 2.0);
331
332                ui.painter().text(
333                    text_pos,
334                    egui::Align2::LEFT_CENTER,
335                    &button.text,
336                    font_id,
337                    text_color,
338                );
339
340                // Handle click
341                if button_response.clicked() && button.enabled {
342                    *open = false;
343                    if let Some(callback) = button.on_click.take() {
344                        callbacks_to_execute.push(callback);
345                    }
346                }
347
348                response = response.union(button_response);
349                current_y += button_height;
350            }
351
352            // Draw divider between groups
353            if group_idx < groups_len - 1 {
354                let divider_y = current_y;
355                ui.painter().line_segment(
356                    [
357                        pos2(sheet_rect.min.x, divider_y),
358                        pos2(sheet_rect.max.x, divider_y),
359                    ],
360                    Stroke::new(1.0, get_global_color("outlineVariant")),
361                );
362            }
363        }
364
365        // Execute callbacks after rendering
366        for callback in callbacks_to_execute {
367            callback();
368        }
369
370        response
371    }
372}
373
374/// Convenience function to create an action sheet
375///
376/// # Example
377/// ```rust
378/// # egui::__run_test_ui(|ui| {
379/// let mut open = false;
380/// action_sheet("my_sheet", &mut open)
381///     .button("Action 1", || {})
382///     .button("Action 2", || {})
383///     .show(ui.ctx());
384/// # });
385/// ```
386pub fn action_sheet<'a>(id: impl Into<Id>, open: &'a mut bool) -> MaterialActionSheet<'a> {
387    MaterialActionSheet::new(id, open)
388}