Skip to main content

autom8/ui/gui/
modal.rs

1//! Reusable modal dialog component for the GUI.
2//!
3//! This module provides a generic modal dialog component that can be used
4//! for confirmation dialogs, alerts, and other modal interactions.
5//!
6//! # Features
7//!
8//! - Semi-transparent backdrop that captures clicks
9//! - Centered dialog with configurable title and message
10//! - Cancel and confirm buttons with customizable labels and colors
11//! - Escape key dismisses the modal
12//! - Follows the application theme (colors, spacing, typography, shadows)
13//!
14//! # Example
15//!
16//! ```ignore
17//! use crate::ui::gui::modal::{Modal, ModalAction, ModalButton};
18//!
19//! let modal = Modal::new("Confirm Delete")
20//!     .message("Are you sure you want to delete this item?")
21//!     .cancel_button(ModalButton::default())
22//!     .confirm_button(
23//!         ModalButton::new("Delete")
24//!             .color(colors::STATUS_ERROR)
25//!     );
26//!
27//! let action = modal.show(ctx);
28//! match action {
29//!     ModalAction::Confirmed => { /* handle confirm */ }
30//!     ModalAction::Cancelled => { /* handle cancel */ }
31//!     ModalAction::None => { /* still open */ }
32//! }
33//! ```
34
35use eframe::egui::{self, Color32, Key, Order, Pos2, Rounding, Sense, Stroke};
36
37use crate::ui::gui::theme::{colors, rounding, shadow, spacing};
38use crate::ui::gui::typography::{self, FontSize, FontWeight};
39
40// ============================================================================
41// Constants
42// ============================================================================
43
44/// Default width of the modal dialog.
45const DIALOG_WIDTH: f32 = 400.0;
46
47/// Padding inside the dialog.
48const DIALOG_PADDING: f32 = 24.0;
49
50/// Height of the action buttons.
51const BUTTON_HEIGHT: f32 = 36.0;
52
53/// Width of the action buttons.
54const BUTTON_WIDTH: f32 = 100.0;
55
56/// Gap between buttons.
57const BUTTON_GAP: f32 = 12.0;
58
59/// Backdrop opacity (0-255).
60const BACKDROP_ALPHA: u8 = 128;
61
62// ============================================================================
63// Modal Button Configuration
64// ============================================================================
65
66/// Configuration for a modal button.
67#[derive(Debug, Clone)]
68pub struct ModalButton {
69    /// The label text displayed on the button.
70    pub label: String,
71    /// The background color of the button.
72    pub fill_color: Color32,
73    /// The text color of the button.
74    pub text_color: Color32,
75    /// Optional border stroke.
76    pub stroke: Option<Stroke>,
77}
78
79impl ModalButton {
80    /// Create a new modal button with the given label.
81    ///
82    /// Default styling is a primary button with accent color.
83    pub fn new(label: impl Into<String>) -> Self {
84        Self {
85            label: label.into(),
86            fill_color: colors::ACCENT,
87            text_color: Color32::WHITE,
88            stroke: None,
89        }
90    }
91
92    /// Set the background fill color.
93    pub fn color(mut self, color: Color32) -> Self {
94        self.fill_color = color;
95        self
96    }
97
98    /// Set the text color.
99    pub fn text_color(mut self, color: Color32) -> Self {
100        self.text_color = color;
101        self
102    }
103
104    /// Add a border stroke.
105    pub fn stroke(mut self, stroke: Stroke) -> Self {
106        self.stroke = Some(stroke);
107        self
108    }
109
110    /// Create a secondary/cancel style button.
111    ///
112    /// Uses a neutral background with border.
113    pub fn secondary(label: impl Into<String>) -> Self {
114        Self {
115            label: label.into(),
116            fill_color: colors::SURFACE_ELEVATED,
117            text_color: colors::TEXT_PRIMARY,
118            stroke: Some(Stroke::new(1.0, colors::BORDER)),
119        }
120    }
121
122    /// Create a destructive/danger style button.
123    ///
124    /// Uses red background with white text.
125    pub fn destructive(label: impl Into<String>) -> Self {
126        Self {
127            label: label.into(),
128            fill_color: colors::STATUS_ERROR,
129            text_color: Color32::WHITE,
130            stroke: None,
131        }
132    }
133}
134
135impl Default for ModalButton {
136    fn default() -> Self {
137        Self::secondary("Cancel")
138    }
139}
140
141// ============================================================================
142// Modal Action Result
143// ============================================================================
144
145/// The result of showing a modal dialog.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ModalAction {
148    /// The user confirmed the action.
149    Confirmed,
150    /// The user cancelled the action (button click, backdrop click, or Escape key).
151    Cancelled,
152    /// No action taken; the modal is still open.
153    None,
154}
155
156impl ModalAction {
157    /// Returns true if the user confirmed.
158    pub fn is_confirmed(&self) -> bool {
159        matches!(self, ModalAction::Confirmed)
160    }
161
162    /// Returns true if the user cancelled.
163    pub fn is_cancelled(&self) -> bool {
164        matches!(self, ModalAction::Cancelled)
165    }
166
167    /// Returns true if the modal is still open (no action taken).
168    pub fn is_open(&self) -> bool {
169        matches!(self, ModalAction::None)
170    }
171}
172
173// ============================================================================
174// Modal Component
175// ============================================================================
176
177/// A reusable modal dialog component.
178///
179/// The modal renders with a semi-transparent backdrop that captures clicks,
180/// a centered dialog box with title, message, and action buttons.
181///
182/// # Usage
183///
184/// Create a modal with [`Modal::new`], configure it with builder methods,
185/// then call [`Modal::show`] to render it and get the user's action.
186#[derive(Debug, Clone)]
187pub struct Modal {
188    /// Unique identifier for the modal (used for egui Area IDs).
189    id: String,
190    /// The title displayed at the top of the modal.
191    title: String,
192    /// The message body displayed below the title.
193    message: String,
194    /// Configuration for the cancel button.
195    /// When None, only the confirm button is shown (for result/info modals).
196    cancel_button: Option<ModalButton>,
197    /// Configuration for the confirm button.
198    confirm_button: ModalButton,
199    /// Width of the dialog.
200    width: f32,
201}
202
203impl Modal {
204    /// Create a new modal with the given title.
205    ///
206    /// The modal is created with default settings:
207    /// - Default "Cancel" button (secondary style)
208    /// - Default "Confirm" button (primary style)
209    /// - Standard dialog width
210    pub fn new(title: impl Into<String>) -> Self {
211        Self {
212            id: "modal".to_string(),
213            title: title.into(),
214            message: String::new(),
215            cancel_button: Some(ModalButton::secondary("Cancel")),
216            confirm_button: ModalButton::new("Confirm"),
217            width: DIALOG_WIDTH,
218        }
219    }
220
221    /// Set a unique ID for the modal.
222    ///
223    /// Use this when you need to have multiple modals that might be shown
224    /// in the same context.
225    pub fn id(mut self, id: impl Into<String>) -> Self {
226        self.id = id.into();
227        self
228    }
229
230    /// Set the message body of the modal.
231    pub fn message(mut self, message: impl Into<String>) -> Self {
232        self.message = message.into();
233        self
234    }
235
236    /// Set the cancel button configuration.
237    pub fn cancel_button(mut self, button: ModalButton) -> Self {
238        self.cancel_button = Some(button);
239        self
240    }
241
242    /// Remove the cancel button (for single-button modals).
243    ///
244    /// US-007: Result modals only need an OK button to dismiss.
245    pub fn no_cancel_button(mut self) -> Self {
246        self.cancel_button = None;
247        self
248    }
249
250    /// Set the confirm button configuration.
251    pub fn confirm_button(mut self, button: ModalButton) -> Self {
252        self.confirm_button = button;
253        self
254    }
255
256    /// Set the dialog width.
257    pub fn width(mut self, width: f32) -> Self {
258        self.width = width;
259        self
260    }
261
262    /// Show the modal and return the user's action.
263    ///
264    /// Returns:
265    /// - [`ModalAction::Confirmed`] if the confirm button was clicked
266    /// - [`ModalAction::Cancelled`] if the cancel button was clicked,
267    ///   the backdrop was clicked, or the Escape key was pressed
268    /// - [`ModalAction::None`] if the modal is still open
269    pub fn show(&self, ctx: &egui::Context) -> ModalAction {
270        let mut action = ModalAction::None;
271
272        // Render backdrop
273        self.render_backdrop(ctx, &mut action);
274
275        // Render dialog
276        self.render_dialog(ctx, &mut action);
277
278        // Handle Escape key
279        if ctx.input(|i| i.key_pressed(Key::Escape)) {
280            action = ModalAction::Cancelled;
281        }
282
283        action
284    }
285
286    /// Render the semi-transparent backdrop.
287    fn render_backdrop(&self, ctx: &egui::Context, action: &mut ModalAction) {
288        let screen_rect = ctx.screen_rect();
289
290        egui::Area::new(egui::Id::new(format!("{}_backdrop", self.id)))
291            .order(Order::Foreground)
292            .fixed_pos(Pos2::ZERO)
293            .show(ctx, |ui| {
294                // Draw semi-transparent backdrop
295                ui.painter().rect_filled(
296                    screen_rect,
297                    Rounding::ZERO,
298                    Color32::from_rgba_unmultiplied(0, 0, 0, BACKDROP_ALPHA),
299                );
300
301                // Capture clicks on backdrop
302                let (_, response) = ui.allocate_exact_size(screen_rect.size(), Sense::click());
303                if response.clicked() {
304                    *action = ModalAction::Cancelled;
305                }
306            });
307    }
308
309    /// Render the dialog box.
310    fn render_dialog(&self, ctx: &egui::Context, action: &mut ModalAction) {
311        let screen_rect = ctx.screen_rect();
312
313        // Calculate dialog position (centered)
314        let dialog_x = (screen_rect.width() - self.width) / 2.0;
315
316        // Estimate dialog height for vertical centering
317        // Title + message + button row + padding
318        let estimated_height = 200.0;
319        let dialog_y = (screen_rect.height() - estimated_height) / 2.0;
320
321        let dialog_pos = Pos2::new(dialog_x, dialog_y);
322
323        egui::Area::new(egui::Id::new(format!("{}_dialog", self.id)))
324            .order(Order::Foreground)
325            .fixed_pos(dialog_pos)
326            .show(ctx, |ui| {
327                egui::Frame::none()
328                    .fill(colors::SURFACE)
329                    .rounding(Rounding::same(rounding::CARD))
330                    .shadow(shadow::elevated())
331                    .stroke(Stroke::new(1.0, colors::BORDER))
332                    .inner_margin(egui::Margin::same(DIALOG_PADDING))
333                    .show(ui, |ui| {
334                        let inner_width = self.width - 2.0 * DIALOG_PADDING;
335                        ui.set_min_width(inner_width);
336                        ui.set_max_width(inner_width);
337
338                        // Title
339                        ui.label(
340                            egui::RichText::new(&self.title)
341                                .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
342                                .color(colors::TEXT_PRIMARY),
343                        );
344
345                        ui.add_space(spacing::MD);
346
347                        // Message
348                        if !self.message.is_empty() {
349                            ui.label(
350                                egui::RichText::new(&self.message)
351                                    .font(typography::font(FontSize::Body, FontWeight::Regular))
352                                    .color(colors::TEXT_SECONDARY),
353                            );
354                        }
355
356                        ui.add_space(spacing::XL);
357
358                        // Button row (right-aligned)
359                        ui.horizontal(|ui| {
360                            // Calculate button layout based on whether cancel button exists
361                            let button_count = if self.cancel_button.is_some() { 2 } else { 1 };
362                            let total_button_width = button_count as f32 * BUTTON_WIDTH
363                                + (button_count - 1) as f32 * BUTTON_GAP;
364                            let available = ui.available_width() - total_button_width;
365                            ui.add_space(available.max(0.0));
366
367                            // Cancel button (optional)
368                            if let Some(cancel_btn) = &self.cancel_button {
369                                let cancel_response = self.render_button(ui, cancel_btn);
370                                if cancel_response.clicked() {
371                                    *action = ModalAction::Cancelled;
372                                }
373                                ui.add_space(BUTTON_GAP);
374                            }
375
376                            // Confirm button
377                            let confirm_response = self.render_button(ui, &self.confirm_button);
378                            if confirm_response.clicked() {
379                                *action = ModalAction::Confirmed;
380                            }
381                        });
382                    });
383            });
384    }
385
386    /// Render a single button and return its response.
387    fn render_button(&self, ui: &mut egui::Ui, button: &ModalButton) -> egui::Response {
388        let mut btn = egui::Button::new(
389            egui::RichText::new(&button.label)
390                .font(typography::font(FontSize::Body, FontWeight::Medium))
391                .color(button.text_color),
392        )
393        .fill(button.fill_color)
394        .rounding(Rounding::same(rounding::BUTTON));
395
396        if let Some(stroke) = button.stroke {
397            btn = btn.stroke(stroke);
398        }
399
400        ui.add_sized([BUTTON_WIDTH, BUTTON_HEIGHT], btn)
401    }
402}
403
404// ============================================================================
405// Convenience Functions
406// ============================================================================
407
408/// Show a simple confirmation dialog.
409///
410/// This is a convenience function for common confirmation patterns.
411///
412/// # Arguments
413///
414/// * `ctx` - The egui context
415/// * `id` - Unique identifier for the modal
416/// * `title` - The dialog title
417/// * `message` - The dialog message
418/// * `confirm_label` - Label for the confirm button
419/// * `destructive` - If true, the confirm button will be styled as destructive (red)
420///
421/// # Returns
422///
423/// The user's action (confirmed, cancelled, or none if still open).
424pub fn confirmation_dialog(
425    ctx: &egui::Context,
426    id: &str,
427    title: &str,
428    message: &str,
429    confirm_label: &str,
430    destructive: bool,
431) -> ModalAction {
432    let confirm_button = if destructive {
433        ModalButton::destructive(confirm_label)
434    } else {
435        ModalButton::new(confirm_label)
436    };
437
438    Modal::new(title)
439        .id(id)
440        .message(message)
441        .confirm_button(confirm_button)
442        .show(ctx)
443}
444
445// ============================================================================
446// Tests
447// ============================================================================
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_modal_button_new() {
455        let button = ModalButton::new("Test");
456        assert_eq!(button.label, "Test");
457        assert_eq!(button.fill_color, colors::ACCENT);
458        assert_eq!(button.text_color, Color32::WHITE);
459        assert!(button.stroke.is_none());
460    }
461
462    #[test]
463    fn test_modal_button_color() {
464        let button = ModalButton::new("Test").color(colors::STATUS_SUCCESS);
465        assert_eq!(button.fill_color, colors::STATUS_SUCCESS);
466    }
467
468    #[test]
469    fn test_modal_button_text_color() {
470        let button = ModalButton::new("Test").text_color(colors::TEXT_PRIMARY);
471        assert_eq!(button.text_color, colors::TEXT_PRIMARY);
472    }
473
474    #[test]
475    fn test_modal_button_stroke() {
476        let stroke = Stroke::new(2.0, colors::BORDER);
477        let button = ModalButton::new("Test").stroke(stroke);
478        assert_eq!(button.stroke, Some(stroke));
479    }
480
481    #[test]
482    fn test_modal_button_secondary() {
483        let button = ModalButton::secondary("Cancel");
484        assert_eq!(button.label, "Cancel");
485        assert_eq!(button.fill_color, colors::SURFACE_ELEVATED);
486        assert_eq!(button.text_color, colors::TEXT_PRIMARY);
487        assert!(button.stroke.is_some());
488    }
489
490    #[test]
491    fn test_modal_button_destructive() {
492        let button = ModalButton::destructive("Delete");
493        assert_eq!(button.label, "Delete");
494        assert_eq!(button.fill_color, colors::STATUS_ERROR);
495        assert_eq!(button.text_color, Color32::WHITE);
496        assert!(button.stroke.is_none());
497    }
498
499    #[test]
500    fn test_modal_button_default() {
501        let button = ModalButton::default();
502        assert_eq!(button.label, "Cancel");
503        assert_eq!(button.fill_color, colors::SURFACE_ELEVATED);
504    }
505
506    #[test]
507    fn test_modal_action_is_confirmed() {
508        assert!(ModalAction::Confirmed.is_confirmed());
509        assert!(!ModalAction::Cancelled.is_confirmed());
510        assert!(!ModalAction::None.is_confirmed());
511    }
512
513    #[test]
514    fn test_modal_action_is_cancelled() {
515        assert!(!ModalAction::Confirmed.is_cancelled());
516        assert!(ModalAction::Cancelled.is_cancelled());
517        assert!(!ModalAction::None.is_cancelled());
518    }
519
520    #[test]
521    fn test_modal_action_is_open() {
522        assert!(!ModalAction::Confirmed.is_open());
523        assert!(!ModalAction::Cancelled.is_open());
524        assert!(ModalAction::None.is_open());
525    }
526
527    #[test]
528    fn test_modal_new() {
529        let modal = Modal::new("Test Title");
530        assert_eq!(modal.title, "Test Title");
531        assert_eq!(modal.message, "");
532        assert_eq!(modal.id, "modal");
533        assert_eq!(modal.width, DIALOG_WIDTH);
534    }
535
536    #[test]
537    fn test_modal_id() {
538        let modal = Modal::new("Test").id("custom_id");
539        assert_eq!(modal.id, "custom_id");
540    }
541
542    #[test]
543    fn test_modal_message() {
544        let modal = Modal::new("Test").message("Test message body");
545        assert_eq!(modal.message, "Test message body");
546    }
547
548    #[test]
549    fn test_modal_cancel_button() {
550        let modal = Modal::new("Test").cancel_button(ModalButton::new("Back"));
551        assert_eq!(modal.cancel_button.as_ref().unwrap().label, "Back");
552    }
553
554    #[test]
555    fn test_modal_no_cancel_button() {
556        let modal = Modal::new("Test").no_cancel_button();
557        assert!(modal.cancel_button.is_none());
558    }
559
560    #[test]
561    fn test_modal_confirm_button() {
562        let modal = Modal::new("Test").confirm_button(ModalButton::destructive("Delete"));
563        assert_eq!(modal.confirm_button.label, "Delete");
564        assert_eq!(modal.confirm_button.fill_color, colors::STATUS_ERROR);
565    }
566
567    #[test]
568    fn test_modal_width() {
569        let modal = Modal::new("Test").width(500.0);
570        assert_eq!(modal.width, 500.0);
571    }
572
573    #[test]
574    fn test_modal_builder_chain() {
575        let modal = Modal::new("Confirm Delete")
576            .id("delete_modal")
577            .message("Are you sure?")
578            .cancel_button(ModalButton::secondary("No"))
579            .confirm_button(ModalButton::destructive("Yes, Delete"))
580            .width(450.0);
581
582        assert_eq!(modal.title, "Confirm Delete");
583        assert_eq!(modal.id, "delete_modal");
584        assert_eq!(modal.message, "Are you sure?");
585        assert_eq!(modal.cancel_button.as_ref().unwrap().label, "No");
586        assert_eq!(modal.confirm_button.label, "Yes, Delete");
587        assert_eq!(modal.width, 450.0);
588    }
589}