Skip to main content

armas_basic/components/
dialog.rs

1//! Dialog Components
2//!
3//! Overlays for focused user interactions.
4//! Styled to match shadcn/ui Dialog conventions.
5
6use crate::animation::{Animation, EasingFunction};
7use crate::Theme;
8use egui::{vec2, Align, Align2, Color32, Key, Layout, Pos2, Sense, Stroke, Ui};
9
10// shadcn/ui Dialog constants
11const CORNER_RADIUS: f32 = 8.0; // rounded-lg
12const PADDING: f32 = 24.0; // p-6
13const GAP: f32 = 16.0; // gap-4
14const HEADER_GAP: f32 = 8.0; // gap-2
15const FOOTER_GAP: f32 = 8.0; // gap-2
16const OVERLAY_ALPHA: u8 = 128; // bg-black/50
17const CLOSE_BUTTON_SIZE: f32 = 16.0;
18
19/// Dialog size presets (max-width based like shadcn)
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum DialogSize {
22    /// Small dialog (384px - sm:max-w-sm)
23    Small,
24    /// Medium dialog (512px - sm:max-w-lg, default)
25    Medium,
26    /// Large dialog (672px - sm:max-w-2xl)
27    Large,
28    /// Extra large dialog (896px - sm:max-w-4xl)
29    ExtraLarge,
30    /// Full screen dialog
31    FullScreen,
32    /// Custom max width
33    Custom(f32),
34}
35
36impl DialogSize {
37    fn max_width(self, screen_width: f32) -> f32 {
38        let max = screen_width - 32.0; // max-w-[calc(100%-2rem)]
39        match self {
40            Self::Small => 384.0_f32.min(max),
41            Self::Medium => 512.0_f32.min(max),
42            Self::Large => 672.0_f32.min(max),
43            Self::ExtraLarge => 896.0_f32.min(max),
44            Self::FullScreen => max,
45            Self::Custom(w) => w.min(max),
46        }
47    }
48}
49
50/// Dialog component styled like shadcn/ui Dialog
51///
52/// # Example
53///
54/// ```rust,no_run
55/// # use egui::Ui;
56/// # use armas_basic::Theme;
57/// # fn example(ctx: &egui::Context, theme: &Theme) {
58/// use armas_basic::components::Dialog;
59///
60/// let mut dialog = Dialog::new("confirm")
61///     .open(true)
62///     .title("Are you sure?")
63///     .description("This action cannot be undone.");
64/// let response = dialog.show(ctx, theme, |ui| {
65///     ui.label("Confirm to proceed.");
66/// });
67/// if response.closed {
68///     // dialog was dismissed
69/// }
70/// # }
71/// ```
72pub struct Dialog {
73    id: egui::Id,
74    title: Option<String>,
75    description: Option<String>,
76    size: DialogSize,
77    closable: bool,
78    fade_animation: Animation<f32>,
79    is_open: Option<bool>,
80}
81
82impl Dialog {
83    /// Create a new dialog with a unique ID
84    pub fn new(id: impl Into<egui::Id>) -> Self {
85        Self {
86            id: id.into(),
87            title: None,
88            description: None,
89            size: DialogSize::Medium,
90            closable: true,
91            fade_animation: Animation::new(0.0, 1.0, 0.15).easing(EasingFunction::CubicOut),
92            is_open: None,
93        }
94    }
95
96    /// Set the dialog to be open (for external control)
97    #[must_use]
98    pub const fn open(mut self, is_open: bool) -> Self {
99        self.is_open = Some(is_open);
100        self
101    }
102
103    /// Set the dialog title
104    #[must_use]
105    pub fn title(mut self, title: impl Into<String>) -> Self {
106        self.title = Some(title.into());
107        self
108    }
109
110    /// Set the dialog description
111    #[must_use]
112    pub fn description(mut self, description: impl Into<String>) -> Self {
113        self.description = Some(description.into());
114        self
115    }
116
117    /// Set the dialog size
118    #[must_use]
119    pub const fn size(mut self, size: DialogSize) -> Self {
120        self.size = size;
121        self
122    }
123
124    /// Set whether the dialog can be closed with ESC or backdrop click
125    #[must_use]
126    pub const fn closable(mut self, closable: bool) -> Self {
127        self.closable = closable;
128        self
129    }
130
131    /// Show the dialog
132    pub fn show(
133        &mut self,
134        ctx: &egui::Context,
135        theme: &Theme,
136        content: impl FnOnce(&mut Ui),
137    ) -> DialogResponse {
138        let mut closed = false;
139        let mut backdrop_clicked = false;
140
141        let state_id = self.id.with("dialog_state");
142        let mut is_open = self
143            .is_open
144            .unwrap_or_else(|| ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false)));
145
146        if !is_open {
147            self.fade_animation.reset();
148            let dummy = egui::Area::new(self.id.with("dialog_empty"))
149                .order(egui::Order::Background)
150                .fixed_pos(egui::Pos2::ZERO)
151                .show(ctx, |_| {})
152                .response;
153            return DialogResponse {
154                response: dummy,
155                closed: false,
156                backdrop_clicked: false,
157            };
158        }
159
160        if !self.fade_animation.is_running() && !self.fade_animation.is_complete() {
161            self.fade_animation.start();
162        }
163
164        let dt = ctx.input(|i| i.unstable_dt);
165        self.fade_animation.update(dt);
166
167        if self.fade_animation.is_running() {
168            ctx.request_repaint();
169        }
170
171        let screen_rect = ctx.content_rect();
172        let dialog_width = self.size.max_width(screen_rect.width());
173        let eased = self.fade_animation.value();
174
175        // Draw backdrop - bg-black/50
176        let backdrop_alpha = (eased * f32::from(OVERLAY_ALPHA)) as u8;
177        let backdrop_color = Color32::from_rgba_unmultiplied(0, 0, 0, backdrop_alpha);
178
179        let backdrop_id = self.id.with("dialog_backdrop");
180        egui::Area::new(backdrop_id)
181            .order(egui::Order::Foreground)
182            .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
183            .show(ctx, |ui| {
184                // Only capture clicks if closable, otherwise just block input
185                let sense = if self.closable {
186                    Sense::click()
187                } else {
188                    Sense::hover()
189                };
190                let backdrop_response = ui.allocate_response(screen_rect.size(), sense);
191                ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
192
193                if self.closable && backdrop_response.clicked() {
194                    is_open = false;
195                    closed = true;
196                    backdrop_clicked = true;
197                    self.fade_animation.reset();
198                }
199            });
200
201        // Draw dialog content
202        let content_id = self.id.with("dialog_content");
203        let area_response = egui::Area::new(content_id)
204            .order(egui::Order::Foreground)
205            .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
206            .show(ctx, |ui| {
207                let frame = egui::Frame::NONE
208                    .fill(theme.background())
209                    .stroke(Stroke::new(1.0, theme.border()))
210                    .corner_radius(CORNER_RADIUS)
211                    .shadow(egui::epaint::Shadow {
212                        offset: [0, 4],
213                        blur: 16,
214                        spread: 0,
215                        color: Color32::from_black_alpha(60),
216                    })
217                    .inner_margin(PADDING);
218
219                frame.show(ui, |ui| {
220                    ui.set_width(dialog_width);
221                    ui.spacing_mut().item_spacing.y = GAP;
222
223                    // Header section
224                    let has_header = self.title.is_some() || self.description.is_some();
225                    if has_header || self.closable {
226                        ui.horizontal(|ui| {
227                            ui.vertical(|ui| {
228                                ui.spacing_mut().item_spacing.y = HEADER_GAP;
229
230                                if let Some(title) = &self.title {
231                                    ui.label(
232                                        egui::RichText::new(title)
233                                            .size(theme.typography.xl)
234                                            .strong()
235                                            .color(theme.foreground()),
236                                    );
237                                }
238
239                                if let Some(desc) = &self.description {
240                                    ui.label(
241                                        egui::RichText::new(desc)
242                                            .size(theme.typography.base)
243                                            .color(theme.muted_foreground()),
244                                    );
245                                }
246                            });
247
248                            ui.allocate_space(
249                                ui.available_size() - vec2(CLOSE_BUTTON_SIZE + 4.0, 0.0),
250                            );
251
252                            if self.closable {
253                                let (close_rect, close_response) = ui.allocate_exact_size(
254                                    vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE),
255                                    Sense::click(),
256                                );
257
258                                let close_color = if close_response.hovered() {
259                                    theme.foreground()
260                                } else {
261                                    theme.muted_foreground()
262                                };
263
264                                let center = close_rect.center();
265                                let half = CLOSE_BUTTON_SIZE * 0.35;
266                                ui.painter().line_segment(
267                                    [
268                                        Pos2::new(center.x - half, center.y - half),
269                                        Pos2::new(center.x + half, center.y + half),
270                                    ],
271                                    Stroke::new(1.5, close_color),
272                                );
273                                ui.painter().line_segment(
274                                    [
275                                        Pos2::new(center.x + half, center.y - half),
276                                        Pos2::new(center.x - half, center.y + half),
277                                    ],
278                                    Stroke::new(1.5, close_color),
279                                );
280
281                                if close_response.clicked() {
282                                    is_open = false;
283                                    closed = true;
284                                    self.fade_animation.reset();
285                                }
286                            }
287                        });
288                    }
289
290                    content(ui);
291                });
292            });
293
294        if self.closable && ctx.input(|i| i.key_pressed(Key::Escape)) {
295            is_open = false;
296            closed = true;
297            self.fade_animation.reset();
298        }
299
300        // Re-check state after content runs (content may have modified it)
301        if self.is_open.is_none() {
302            let state_after_content =
303                ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(true));
304            if !state_after_content && is_open {
305                // Content closed the dialog
306                closed = true;
307                self.fade_animation.reset();
308            }
309
310            // Save the updated state back to context
311            ctx.data_mut(|d| d.insert_temp(state_id, is_open));
312        }
313
314        DialogResponse {
315            response: area_response.response,
316            closed,
317            backdrop_clicked,
318        }
319    }
320}
321
322impl Default for Dialog {
323    fn default() -> Self {
324        Self::new("dialog")
325    }
326}
327
328/// Response from a dialog
329pub struct DialogResponse {
330    /// The UI response
331    pub response: egui::Response,
332    /// Whether the dialog was closed this frame
333    pub closed: bool,
334    /// Whether the backdrop was clicked
335    pub backdrop_clicked: bool,
336}
337
338// ============================================================================
339// Helper functions for building dialog content
340// ============================================================================
341
342/// Helper to render a dialog footer (right-aligned buttons)
343pub fn dialog_footer(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
344    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
345        ui.spacing_mut().item_spacing.x = FOOTER_GAP;
346        content(ui);
347    });
348}