egui_material3/
dialog.rs

1use eframe::egui::{self, Color32, Stroke, Ui, Context, Modal, Id, Vec2, Sense, Response};
2use crate::get_global_color;
3
4/// Material Design dialog types following Material Design 3 specifications
5#[derive(Clone, Copy, PartialEq)]
6pub enum DialogType {
7    /// Standard dialog for general purpose use
8    Standard,
9    /// Alert dialog for important notifications requiring acknowledgment
10    Alert,
11    /// Confirmation dialog for confirming actions before proceeding
12    Confirm,
13    /// Form dialog containing input fields and form elements
14    Form,
15}
16
17/// Material Design dialog component following Material Design 3 specifications
18///
19/// Dialogs interrupt users with overlaid content that requires a response.
20/// They appear above all other content and disable all app functionality when shown.
21///
22/// ## Usage Examples
23/// ```rust
24/// # egui::__run_test_ui(|ui| {
25/// let mut dialog_open = false;
26/// 
27/// // Basic dialog
28/// let dialog = MaterialDialog::new("my_dialog", "Confirm Action", &mut dialog_open)
29///     .content(|ui| {
30///         ui.label("Are you sure you want to proceed?");
31///     })
32///     .action("Cancel", ActionType::Text, || {
33///         // Cancel action
34///     })
35///     .action("Confirm", ActionType::Filled, || {
36///         // Confirm action  
37///     });
38/// 
39/// dialog.show(ui.ctx());
40/// # });
41/// ```
42///
43/// ## Material Design Spec
44/// - Max width: 560dp on large screens
45/// - Corner radius: 28dp
46/// - Elevation: 6dp (24dp shadow)
47/// - Surface color background
48/// - Minimum touch target: 48x48dp for actions
49pub struct MaterialDialog<'a> {
50    /// Unique identifier for the dialog
51    id: Id,
52    /// Dialog title text
53    title: String,
54    /// Mutable reference to dialog open state
55    open: &'a mut bool,
56    /// Type of dialog (affects styling and behavior)
57    dialog_type: DialogType,
58    /// Optional icon to display in dialog header
59    icon: Option<String>,
60    /// Content rendering function called once
61    content: Box<dyn FnOnce(&mut Ui) + 'a>,
62    /// List of action buttons at the bottom of the dialog
63    actions: Vec<DialogAction<'a>>,
64    /// Whether this is a quick/temporary dialog
65    quick: bool,
66    /// Whether to disable focus trapping within the dialog
67    no_focus_trap: bool,
68    /// Maximum width constraint for the dialog
69    max_width: Option<f32>,
70}
71
72/// Represents an action button in a Material Design dialog
73pub struct DialogAction<'a> {
74    /// Button text label
75    text: String,
76    /// Visual style of the action button
77    action_type: ActionType,
78    /// Whether the action is currently enabled
79    _enabled: bool,
80    /// Callback function executed when action is triggered
81    action: Box<dyn FnOnce() + 'a>,
82}
83
84/// Material Design action button styles for dialogs
85#[derive(Clone, Copy, PartialEq)]
86pub enum ActionType {
87    /// Text button - lowest emphasis, used for secondary actions
88    Text,
89    /// Filled tonal button - medium emphasis, used for secondary actions
90    FilledTonal,
91    /// Filled button - highest emphasis, used for primary actions
92    Filled,
93}
94
95impl<'a> MaterialDialog<'a> {
96    /// Create a new Material Design dialog
97    /// 
98    /// ## Parameters
99    /// - `id`: Unique identifier for the dialog (used for egui state)
100    /// - `title`: Title text displayed at the top of the dialog
101    /// - `open`: Mutable reference to boolean controlling dialog visibility
102    /// 
103    /// ## Returns
104    /// A new MaterialDialog instance ready for customization
105    pub fn new(
106        id: impl Into<Id>,
107        title: impl Into<String>,
108        open: &'a mut bool,
109    ) -> Self {
110        Self {
111            id: id.into(),
112            title: title.into(),
113            open,
114            dialog_type: DialogType::Standard,
115            icon: None,
116            content: Box::new(|_| {}),
117            actions: Vec::new(),
118            quick: false,
119            no_focus_trap: false,
120            max_width: None,
121        }
122    }
123
124    /// Set the dialog type (affects styling and behavior)
125    /// 
126    /// ## Parameters
127    /// - `dialog_type`: The type of dialog to display
128    /// 
129    /// ## Returns
130    /// Self for method chaining
131    pub fn dialog_type(mut self, dialog_type: DialogType) -> Self {
132        self.dialog_type = dialog_type;
133        self
134    }
135
136    /// Set an optional icon to display in the dialog header
137    /// 
138    /// ## Parameters
139    /// - `icon`: The icon to display (as a string identifier)
140    /// 
141    /// ## Returns
142    /// Self for method chaining
143    pub fn icon(mut self, icon: impl Into<String>) -> Self {
144        self.icon = Some(icon.into());
145        self
146    }
147
148    /// Set the content of the dialog
149    /// 
150    /// ## Parameters
151    /// - `content`: A closure that renders the content UI
152    /// 
153    /// ## Returns
154    /// Self for method chaining
155    pub fn content<F>(mut self, content: F) -> Self
156    where
157        F: FnOnce(&mut Ui) + 'a,
158    {
159        self.content = Box::new(content);
160        self
161    }
162
163    /// Set whether this is a quick/temporary dialog
164    /// 
165    /// ## Parameters
166    /// - `quick`: If true, the dialog is considered quick/temporary
167    /// 
168    /// ## Returns
169    /// Self for method chaining
170    pub fn quick(mut self, quick: bool) -> Self {
171        self.quick = quick;
172        self
173    }
174
175    /// Set whether to disable focus trapping within the dialog
176    /// 
177    /// ## Parameters
178    /// - `no_focus_trap`: If true, focus trapping is disabled
179    /// 
180    /// ## Returns
181    /// Self for method chaining
182    pub fn no_focus_trap(mut self, no_focus_trap: bool) -> Self {
183        self.no_focus_trap = no_focus_trap;
184        self
185    }
186
187    /// Set the maximum width constraint for the dialog
188    /// 
189    /// ## Parameters
190    /// - `width`: The maximum width in pixels
191    /// 
192    /// ## Returns
193    /// Self for method chaining
194    pub fn max_width(mut self, width: f32) -> Self {
195        self.max_width = Some(width);
196        self
197    }
198
199    /// Add a text action button to the dialog
200    /// 
201    /// ## Parameters
202    /// - `text`: The text label for the button
203    /// - `action`: A closure that is called when the button is clicked
204    /// 
205    /// ## Returns
206    /// Self for method chaining
207    pub fn text_action<F>(mut self, text: impl Into<String>, action: F) -> Self
208    where
209        F: FnOnce() + 'a,
210    {
211        self.actions.push(DialogAction {
212            text: text.into(),
213            action_type: ActionType::Text,
214            _enabled: true,
215            action: Box::new(action),
216        });
217        self
218    }
219
220    /// Add a filled tonal action button to the dialog
221    /// 
222    /// ## Parameters
223    /// - `text`: The text label for the button
224    /// - `action`: A closure that is called when the button is clicked
225    /// 
226    /// ## Returns
227    /// Self for method chaining
228    pub fn filled_tonal_action<F>(mut self, text: impl Into<String>, action: F) -> Self
229    where
230        F: FnOnce() + 'a,
231    {
232        self.actions.push(DialogAction {
233            text: text.into(),
234            action_type: ActionType::FilledTonal,
235            _enabled: true,
236            action: Box::new(action),
237        });
238        self
239    }
240
241    /// Add a filled action button to the dialog
242    /// 
243    /// ## Parameters
244    /// - `text`: The text label for the button
245    /// - `action`: A closure that is called when the button is clicked
246    /// 
247    /// ## Returns
248    /// Self for method chaining
249    pub fn filled_action<F>(mut self, text: impl Into<String>, action: F) -> Self
250    where
251        F: FnOnce() + 'a,
252    {
253        self.actions.push(DialogAction {
254            text: text.into(),
255            action_type: ActionType::Filled,
256            _enabled: true,
257            action: Box::new(action),
258        });
259        self
260    }
261
262    /// Backward compatibility methods
263    /// 
264    /// These methods exist to support older code that used different naming conventions for actions.
265    /// They are functionally equivalent to the more descriptively named methods introduced later.
266    /// 
267    /// ## Parameters
268    /// - `text`: The text label for the button
269    /// - `action`: A closure that is called when the button is clicked
270    /// 
271    /// ## Returns
272    /// Self for method chaining
273    pub fn action<F>(self, text: impl Into<String>, action: F) -> Self
274    where
275        F: FnOnce() + 'a,
276    {
277        self.text_action(text, action)
278    }
279
280    /// Backward compatibility method for primary actions
281    /// 
282    /// This method is provided for convenience and is functionally equivalent to `filled_action`.
283    /// 
284    /// ## Parameters
285    /// - `text`: The text label for the button
286    /// - `action`: A closure that is called when the button is clicked
287    /// 
288    /// ## Returns
289    /// Self for method chaining
290    pub fn primary_action<F>(self, text: impl Into<String>, action: F) -> Self
291    where
292        F: FnOnce() + 'a,
293    {
294        self.filled_action(text, action)
295    }
296
297    /// Show the dialog, rendering it in the given context
298    /// 
299    /// ## Parameters
300    /// - `ctx`: The egui context used for rendering the dialog
301    /// 
302    /// ## Behavior
303    /// - The dialog will be displayed as an overlay, blocking interaction with other windows
304    /// - Clicking outside the dialog or pressing the escape key will close the dialog
305    /// - Action buttons will execute their associated actions when clicked
306    pub fn show(mut self, ctx: &Context) {
307        if !*self.open {
308            return;
309        }
310
311        let mut should_close = false;
312        let mut pending_actions = Vec::new();
313        
314        // Extract values we need before moving into closure
315        let dialog_width = self.max_width.unwrap_or(match self.dialog_type {
316            DialogType::Alert => 280.0,
317            DialogType::Confirm => 320.0,
318            DialogType::Form => 800.0,
319            DialogType::Standard => 400.0,
320        });
321        
322        let title = self.title.clone();
323        let icon = self.icon.clone();
324        let actions = std::mem::take(&mut self.actions);
325        let open_ref = self.open as *mut bool;
326
327        let modal = Modal::new(self.id).show(ctx, |ui| {
328            // ui.set_width(dialog_width);
329            ui.set_min_width(dialog_width);
330            ui.set_height(200.0);
331            
332            // Material Design colors
333            let surface_container_high = get_global_color("surfaceContainerHigh");
334            let on_surface = get_global_color("onSurface");
335            let on_surface_variant = get_global_color("onSurfaceVariant");
336            
337            // Set dialog background
338            ui.style_mut().visuals.window_fill = surface_container_high;
339            ui.style_mut().visuals.panel_fill = surface_container_high;
340            ui.style_mut().visuals.window_stroke = Stroke::NONE;
341            
342            ui.vertical(|ui| {
343                ui.add_space(24.0);
344                
345                // Icon (if present) - positioned above headline
346                if let Some(ref icon) = icon {
347                    ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
348                        ui.add_space(0.0);
349                        // Material icon placeholder
350                        ui.label(egui::RichText::new(icon).size(24.0).color(on_surface_variant));
351                        ui.add_space(16.0);
352                    });
353                }
354                
355                // Headline
356                ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
357                    ui.add_space(24.0);
358                    ui.label(egui::RichText::new(&title)
359                        .size(24.0)
360                        .color(on_surface)
361                        .family(egui::FontFamily::Proportional));
362                    ui.add_space(24.0);
363                });
364                
365                ui.add_space(16.0);
366                
367                // Content area with proper padding
368                ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
369                    ui.add_space(24.0);
370                    ui.vertical(|ui| {
371                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
372                        (self.content)(ui);
373                    });
374                    ui.add_space(24.0);
375                });
376                
377                ui.add_space(24.0);
378                
379                // Actions area
380                if !actions.is_empty() {
381                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
382                        ui.add_space(24.0);
383                        
384                        for (index, action) in actions.into_iter().enumerate().rev() {
385                            let button_response = Self::draw_action_button_static(ui, &action);
386                            
387                            if button_response.clicked() {
388                                pending_actions.push((index, action.action));
389                            }
390                            
391                            ui.add_space(8.0);
392                        }
393                        
394                        ui.add_space(16.0); // Extra space from right edge
395                    });
396                    
397                    ui.add_space(24.0);
398                }
399            });
400        });
401
402        // Execute pending actions
403        for (_index, action) in pending_actions {
404            action();
405            should_close = true;
406        }
407
408        // Handle modal close events (escape key, click outside, etc.)
409        if modal.should_close() || should_close {
410            unsafe { *open_ref = false; }
411        }
412    }
413    
414    fn draw_action_button_static(ui: &mut Ui, action: &DialogAction) -> Response {
415        let primary = get_global_color("primary");
416        let on_primary = get_global_color("onPrimary");
417        let secondary_container = get_global_color("secondaryContainer");
418        let on_secondary_container = get_global_color("onSecondaryContainer");
419        let _on_surface_variant = get_global_color("onSurfaceVariant");
420        
421        let text_width = ui.fonts(|fonts| {
422            fonts.layout_no_wrap(
423                action.text.clone(),
424                egui::FontId::default(),
425                Color32::WHITE
426            ).rect.width()
427        });
428        
429        let button_width = (text_width + 24.0).max(64.0);
430        let button_height = 40.0;
431        let desired_size = Vec2::new(button_width, button_height);
432        
433        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
434        
435        let (bg_color, text_color, _border_color) = match action.action_type {
436            ActionType::Text => {
437                if response.hovered() {
438                    (
439                        Color32::from_rgba_premultiplied(primary.r(), primary.g(), primary.b(), 20), // 8% opacity state layer
440                        primary,
441                        Color32::TRANSPARENT,
442                    )
443                } else {
444                    (Color32::TRANSPARENT, primary, Color32::TRANSPARENT)
445                }
446            }
447            ActionType::FilledTonal => {
448                if response.hovered() {
449                    (
450                        secondary_container,
451                        on_secondary_container,
452                        Color32::TRANSPARENT,
453                    )
454                } else {
455                    (secondary_container, on_secondary_container, Color32::TRANSPARENT)
456                }
457            }
458            ActionType::Filled => {
459                if response.hovered() {
460                    (
461                        primary,
462                        on_primary,
463                        Color32::TRANSPARENT,
464                    )
465                } else {
466                    (primary, on_primary, Color32::TRANSPARENT)
467                }
468            }
469        };
470        
471        // Draw button background
472        ui.painter().rect_filled(
473            rect,
474            20.0, // Full rounded corners
475            bg_color,
476        );
477        
478        // Draw state layer for pressed state
479        if response.is_pointer_button_down_on() {
480            let pressed_overlay = Color32::from_rgba_premultiplied(text_color.r(), text_color.g(), text_color.b(), 31); // 12% opacity
481            ui.painter().rect_filled(
482                rect,
483                20.0,
484                pressed_overlay,
485            );
486        }
487        
488        // Draw button text
489        ui.painter().text(
490            rect.center(),
491            egui::Align2::CENTER_CENTER,
492            &action.text,
493            egui::FontId::proportional(14.0),
494            text_color,
495        );
496        
497        response
498    }
499
500    fn _draw_action_button(&self, ui: &mut Ui, action: &DialogAction) -> Response {
501        Self::draw_action_button_static(ui, action)
502    }
503}
504
505// Convenience constructors
506/// Create a standard Material Design dialog
507/// 
508/// ## Parameters
509/// - `id`: Unique identifier for the dialog (used for egui state)
510/// - `title`: Title text displayed at the top of the dialog
511/// - `open`: Mutable reference to boolean controlling dialog visibility
512/// 
513/// ## Returns
514/// A new MaterialDialog instance configured as a standard dialog
515pub fn dialog(
516    id: impl Into<egui::Id>,
517    title: impl Into<String>,
518    open: &mut bool,
519) -> MaterialDialog<'_> {
520    MaterialDialog::new(id, title, open)
521}
522
523/// Create an alert dialog
524/// 
525/// ## Parameters
526/// - `id`: Unique identifier for the dialog (used for egui state)
527/// - `title`: Title text displayed at the top of the dialog
528/// - `open`: Mutable reference to boolean controlling dialog visibility
529/// 
530/// ## Returns
531/// A new MaterialDialog instance configured as an alert dialog
532pub fn alert_dialog(
533    id: impl Into<egui::Id>,
534    title: impl Into<String>,
535    open: &mut bool,
536) -> MaterialDialog<'_> {
537    MaterialDialog::new(id, title, open).dialog_type(DialogType::Alert)
538}
539
540/// Create a confirmation dialog
541/// 
542/// ## Parameters
543/// - `id`: Unique identifier for the dialog (used for egui state)
544/// - `title`: Title text displayed at the top of the dialog
545/// - `open`: Mutable reference to boolean controlling dialog visibility
546/// 
547/// ## Returns
548/// A new MaterialDialog instance configured as a confirmation dialog
549pub fn confirm_dialog(
550    id: impl Into<egui::Id>,
551    title: impl Into<String>,
552    open: &mut bool,
553) -> MaterialDialog<'_> {
554    MaterialDialog::new(id, title, open).dialog_type(DialogType::Confirm)
555}
556
557/// Create a form dialog
558/// 
559/// ## Parameters
560/// - `id`: Unique identifier for the dialog (used for egui state)
561/// - `title`: Title text displayed at the top of the dialog
562/// - `open`: Mutable reference to boolean controlling dialog visibility
563/// 
564/// ## Returns
565/// A new MaterialDialog instance configured as a form dialog
566pub fn form_dialog(
567    id: impl Into<egui::Id>,
568    title: impl Into<String>,
569    open: &mut bool,
570) -> MaterialDialog<'_> {
571    MaterialDialog::new(id, title, open).dialog_type(DialogType::Form)
572}