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}