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, 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    fixed_height: Option<f32>,
78    closable: bool,
79    fade_animation: Animation<f32>,
80    is_open: Option<bool>,
81}
82
83impl Dialog {
84    /// Create a new dialog with a unique ID
85    pub fn new(id: impl Into<egui::Id>) -> Self {
86        Self {
87            id: id.into(),
88            title: None,
89            description: None,
90            size: DialogSize::Medium,
91            fixed_height: None,
92            closable: true,
93            fade_animation: Animation::new(0.0, 1.0, 0.15).easing(EasingFunction::CubicOut),
94            is_open: None,
95        }
96    }
97
98    /// Set the dialog to be open (for external control)
99    #[must_use]
100    pub const fn open(mut self, is_open: bool) -> Self {
101        self.is_open = Some(is_open);
102        self
103    }
104
105    /// Set the dialog title
106    #[must_use]
107    pub fn title(mut self, title: impl Into<String>) -> Self {
108        self.title = Some(title.into());
109        self
110    }
111
112    /// Set the dialog description
113    #[must_use]
114    pub fn description(mut self, description: impl Into<String>) -> Self {
115        self.description = Some(description.into());
116        self
117    }
118
119    /// Set the dialog size (controls width)
120    #[must_use]
121    pub const fn size(mut self, size: DialogSize) -> Self {
122        self.size = size;
123        self
124    }
125
126    /// Fix the dialog height. When set, content will scroll rather than expanding the dialog.
127    #[must_use]
128    pub const fn height(mut self, height: f32) -> Self {
129        self.fixed_height = Some(height);
130        self
131    }
132
133    /// Set whether the dialog can be closed with ESC or backdrop click
134    #[must_use]
135    pub const fn closable(mut self, closable: bool) -> Self {
136        self.closable = closable;
137        self
138    }
139
140    /// Show the dialog
141    pub fn show(
142        &mut self,
143        ctx: &egui::Context,
144        theme: &Theme,
145        content: impl FnOnce(&mut Ui),
146    ) -> DialogResponse {
147        let mut closed = false;
148        let mut backdrop_clicked = false;
149
150        let state_id = self.id.with("dialog_state");
151        let mut is_open = self
152            .is_open
153            .unwrap_or_else(|| ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false)));
154
155        if !is_open {
156            self.fade_animation.reset();
157            let dummy = egui::Area::new(self.id.with("dialog_empty"))
158                .order(egui::Order::Background)
159                .fixed_pos(egui::Pos2::ZERO)
160                .show(ctx, |_| {})
161                .response;
162            return DialogResponse {
163                response: dummy,
164                closed: false,
165                backdrop_clicked: false,
166            };
167        }
168
169        if !self.fade_animation.is_running() && !self.fade_animation.is_complete() {
170            self.fade_animation.start();
171        }
172
173        let dt = ctx.input(|i| i.unstable_dt);
174        self.fade_animation.update(dt);
175
176        if self.fade_animation.is_running() {
177            ctx.request_repaint();
178        }
179
180        let screen_rect = ctx.content_rect();
181        let dialog_width = self.size.max_width(screen_rect.width());
182        let eased = self.fade_animation.value();
183
184        // Draw backdrop - bg-black/50
185        let backdrop_alpha = (eased * f32::from(OVERLAY_ALPHA)) as u8;
186        let backdrop_color = Color32::from_rgba_unmultiplied(0, 0, 0, backdrop_alpha);
187
188        let backdrop_id = self.id.with("dialog_backdrop");
189        egui::Area::new(backdrop_id)
190            .order(egui::Order::Foreground)
191            .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
192            .show(ctx, |ui| {
193                // Only capture clicks if closable, otherwise just block input
194                let sense = if self.closable {
195                    Sense::click()
196                } else {
197                    Sense::hover()
198                };
199                let backdrop_response = ui.allocate_response(screen_rect.size(), sense);
200                ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
201
202                if self.closable && backdrop_response.clicked() {
203                    is_open = false;
204                    closed = true;
205                    backdrop_clicked = true;
206                    self.fade_animation.reset();
207                }
208            });
209
210        // Draw dialog content — use Window when fixed_height is set for true size enforcement,
211        // otherwise fall back to Area (auto-sized).
212        let frame = egui::Frame::NONE
213            .fill(theme.background())
214            .stroke(Stroke::new(1.0, theme.border()))
215            .corner_radius(CORNER_RADIUS)
216            .shadow(egui::epaint::Shadow {
217                offset: [0, 4],
218                blur: 16,
219                spread: 0,
220                color: Color32::from_black_alpha(60),
221            })
222            .inner_margin(PADDING);
223
224        let mut win_closed = false;
225        let area_response = if let Some(h) = self.fixed_height {
226            let mut win_open = true;
227            let resp = egui::Window::new("")
228                .id(self.id.with("dialog_content"))
229                .open(&mut win_open)
230                .order(egui::Order::Foreground)
231                .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
232                .fixed_size(vec2(dialog_width, h))
233                .title_bar(false)
234                .resizable(false)
235                .collapsible(false)
236                .frame(frame)
237                .show(ctx, |ui| {
238                    ui.spacing_mut().item_spacing.y = GAP;
239                    render_header(
240                        ui,
241                        theme,
242                        self.title.as_ref(),
243                        self.description.as_ref(),
244                        self.closable,
245                        &mut closed,
246                        &mut self.fade_animation,
247                    );
248                    content(ui);
249                });
250            if !win_open {
251                win_closed = true;
252            }
253            resp.map_or_else(
254                || {
255                    ctx.data_mut(|_| {});
256                    egui::Area::new(self.id.with("dialog_empty2"))
257                        .order(egui::Order::Background)
258                        .fixed_pos(egui::Pos2::ZERO)
259                        .show(ctx, |_| {})
260                        .response
261                },
262                |r| r.response,
263            )
264        } else {
265            let content_id = self.id.with("dialog_content");
266            egui::Area::new(content_id)
267                .order(egui::Order::Foreground)
268                .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
269                .show(ctx, |ui| {
270                    frame.show(ui, |ui| {
271                        ui.set_width(dialog_width);
272                        ui.spacing_mut().item_spacing.y = GAP;
273                        render_header(
274                            ui,
275                            theme,
276                            self.title.as_ref(),
277                            self.description.as_ref(),
278                            self.closable,
279                            &mut closed,
280                            &mut self.fade_animation,
281                        );
282                        content(ui);
283                    });
284                })
285                .response
286        };
287
288        if win_closed {
289            closed = true;
290            self.fade_animation.reset();
291        }
292
293        if self.closable && ctx.input(|i| i.key_pressed(Key::Escape)) {
294            is_open = false;
295            closed = true;
296            self.fade_animation.reset();
297        }
298
299        // Re-check state after content runs (content may have modified it)
300        if self.is_open.is_none() {
301            let state_after_content =
302                ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(true));
303            if !state_after_content && is_open {
304                // Content closed the dialog
305                closed = true;
306                self.fade_animation.reset();
307            }
308
309            // Save the updated state back to context
310            ctx.data_mut(|d| d.insert_temp(state_id, is_open));
311        }
312
313        DialogResponse {
314            response: area_response,
315            closed,
316            backdrop_clicked,
317        }
318    }
319}
320
321impl Default for Dialog {
322    fn default() -> Self {
323        Self::new("dialog")
324    }
325}
326
327/// Response from a dialog
328pub struct DialogResponse {
329    /// The UI response
330    pub response: egui::Response,
331    /// Whether the dialog was closed this frame
332    pub closed: bool,
333    /// Whether the backdrop was clicked
334    pub backdrop_clicked: bool,
335}
336
337// ============================================================================
338// Helper functions for building dialog content
339// ============================================================================
340
341fn render_header(
342    ui: &mut Ui,
343    theme: &Theme,
344    title: Option<&String>,
345    description: Option<&String>,
346    closable: bool,
347    closed: &mut bool,
348    fade_animation: &mut crate::animation::Animation<f32>,
349) {
350    let has_header = title.is_some() || description.is_some();
351    if !has_header && !closable {
352        return;
353    }
354    ui.horizontal(|ui| {
355        ui.vertical(|ui| {
356            ui.spacing_mut().item_spacing.y = HEADER_GAP;
357            if let Some(t) = title {
358                ui.label(
359                    egui::RichText::new(t)
360                        .size(theme.typography.xl)
361                        .strong()
362                        .color(theme.foreground()),
363                );
364            }
365            if let Some(d) = description {
366                ui.label(
367                    egui::RichText::new(d)
368                        .size(theme.typography.base)
369                        .color(theme.muted_foreground()),
370                );
371            }
372        });
373        ui.allocate_space(ui.available_size() - vec2(CLOSE_BUTTON_SIZE + 4.0, 0.0));
374        if closable {
375            let (close_rect, close_response) =
376                ui.allocate_exact_size(vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE), Sense::click());
377            let close_color = if close_response.hovered() {
378                theme.foreground()
379            } else {
380                theme.muted_foreground()
381            };
382            crate::icon::draw_close(ui.painter(), close_rect, close_color);
383            if close_response.clicked() {
384                *closed = true;
385                fade_animation.reset();
386            }
387        }
388    });
389}
390
391/// Helper to render a dialog footer (right-aligned buttons)
392pub fn dialog_footer(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
393    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
394        ui.spacing_mut().item_spacing.x = FOOTER_GAP;
395        content(ui);
396    });
397}