Skip to main content

jag_ui/elements/
modal.rs

1//! Modal dialog overlay element.
2
3use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// Result of a click on the modal.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ModalClickResult {
17    /// The close button was clicked.
18    CloseButton,
19    /// A modal button was clicked (index into `buttons`).
20    Button(usize),
21    /// The background scrim was clicked.
22    Background,
23    /// Somewhere on the panel body was clicked.
24    Panel,
25}
26
27/// Configuration for a modal action button.
28#[derive(Debug, Clone)]
29pub struct ModalButton {
30    pub label: String,
31    pub primary: bool,
32}
33
34impl ModalButton {
35    pub fn new(label: impl Into<String>) -> Self {
36        Self {
37            label: label.into(),
38            primary: false,
39        }
40    }
41
42    pub fn primary(label: impl Into<String>) -> Self {
43        Self {
44            label: label.into(),
45            primary: true,
46        }
47    }
48}
49
50/// An overlay dialog centered on the screen with title, content, and
51/// action buttons.
52pub struct Modal {
53    /// Overall bounding rect (typically the full viewport).
54    pub rect: Rect,
55    /// Panel width.
56    pub panel_width: f32,
57    /// Panel height.
58    pub panel_height: f32,
59    /// Title text.
60    pub title: String,
61    /// Content/body text (newlines supported).
62    pub content: String,
63    /// Action buttons rendered at the bottom of the panel.
64    pub buttons: Vec<ModalButton>,
65    /// Semi-transparent overlay color.
66    pub overlay_color: ColorLinPremul,
67    /// Panel background.
68    pub panel_bg: ColorLinPremul,
69    /// Panel border color.
70    pub panel_border_color: ColorLinPremul,
71    /// Title text color.
72    pub title_color: ColorLinPremul,
73    /// Content text color.
74    pub content_color: ColorLinPremul,
75    /// Title font size.
76    pub title_size: f32,
77    /// Content font size.
78    pub content_size: f32,
79    /// Button label font size.
80    pub button_label_size: f32,
81    /// Panel corner radius.
82    pub panel_radius: f32,
83    /// Whether the modal is currently visible.
84    pub visible: bool,
85    /// Focus identifier.
86    pub focus_id: FocusId,
87}
88
89impl Modal {
90    /// Create a modal with default styling.
91    pub fn new(
92        viewport: Rect,
93        title: impl Into<String>,
94        content: impl Into<String>,
95        buttons: Vec<ModalButton>,
96    ) -> Self {
97        Self {
98            rect: viewport,
99            panel_width: 480.0,
100            panel_height: 300.0,
101            title: title.into(),
102            content: content.into(),
103            buttons,
104            overlay_color: ColorLinPremul::from_srgba_u8([0, 0, 0, 140]),
105            panel_bg: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
106            panel_border_color: ColorLinPremul::from_srgba_u8([200, 200, 200, 255]),
107            title_color: ColorLinPremul::from_srgba_u8([20, 20, 20, 255]),
108            content_color: ColorLinPremul::from_srgba_u8([60, 60, 60, 255]),
109            title_size: 20.0,
110            content_size: 14.0,
111            button_label_size: 14.0,
112            panel_radius: 8.0,
113            visible: true,
114            focus_id: FocusId(0),
115        }
116    }
117
118    /// Compute the centered panel rectangle.
119    pub fn panel_rect(&self) -> Rect {
120        Rect {
121            x: self.rect.x + (self.rect.w - self.panel_width) * 0.5,
122            y: self.rect.y + (self.rect.h - self.panel_height) * 0.5,
123            w: self.panel_width,
124            h: self.panel_height,
125        }
126    }
127
128    /// Close button rectangle (top-right of panel).
129    pub fn close_button_rect(&self) -> Rect {
130        let panel = self.panel_rect();
131        let size = 32.0;
132        Rect {
133            x: panel.x + panel.w - size - 8.0,
134            y: panel.y + 8.0,
135            w: size,
136            h: size,
137        }
138    }
139
140    /// Compute rectangles for all action buttons (centered at bottom).
141    pub fn button_rects(&self) -> Vec<Rect> {
142        let panel = self.panel_rect();
143        let btn_h = 36.0;
144        let btn_w = 100.0;
145        let spacing = 12.0;
146        let n = self.buttons.len();
147        if n == 0 {
148            return vec![];
149        }
150        let total_w = btn_w * n as f32 + spacing * (n - 1) as f32;
151        let start_x = panel.x + (panel.w - total_w) * 0.5;
152        let y = panel.y + panel.h - 20.0 - btn_h;
153        (0..n)
154            .map(|i| Rect {
155                x: start_x + (btn_w + spacing) * i as f32,
156                y,
157                w: btn_w,
158                h: btn_h,
159            })
160            .collect()
161    }
162
163    /// Handle a click and return what was hit.
164    pub fn handle_click(&self, x: f32, y: f32) -> ModalClickResult {
165        let panel = self.panel_rect();
166        let in_panel =
167            x >= panel.x && x <= panel.x + panel.w && y >= panel.y && y <= panel.y + panel.h;
168
169        if !in_panel {
170            return ModalClickResult::Background;
171        }
172
173        // Close button
174        let close = self.close_button_rect();
175        if x >= close.x && x <= close.x + close.w && y >= close.y && y <= close.y + close.h {
176            return ModalClickResult::CloseButton;
177        }
178
179        // Action buttons
180        for (i, r) in self.button_rects().iter().enumerate() {
181            if x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h {
182                return ModalClickResult::Button(i);
183            }
184        }
185
186        ModalClickResult::Panel
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Element trait
192// ---------------------------------------------------------------------------
193
194impl Element for Modal {
195    fn rect(&self) -> Rect {
196        self.rect
197    }
198
199    fn set_rect(&mut self, rect: Rect) {
200        self.rect = rect;
201    }
202
203    fn render(&self, canvas: &mut Canvas, z: i32) {
204        if !self.visible {
205            return;
206        }
207
208        // 1. Overlay scrim
209        canvas.fill_rect(
210            self.rect.x,
211            self.rect.y,
212            self.rect.w,
213            self.rect.h,
214            Brush::Solid(self.overlay_color),
215            z,
216        );
217
218        // 2. Panel
219        let panel = self.panel_rect();
220        let rrect = RoundedRect {
221            rect: panel,
222            radii: RoundedRadii {
223                tl: self.panel_radius,
224                tr: self.panel_radius,
225                br: self.panel_radius,
226                bl: self.panel_radius,
227            },
228        };
229        jag_surface::shapes::draw_snapped_rounded_rectangle(
230            canvas,
231            rrect,
232            Some(Brush::Solid(self.panel_bg)),
233            Some(1.0),
234            Some(Brush::Solid(self.panel_border_color)),
235            z + 1,
236        );
237
238        // 3. Close button "X"
239        let close = self.close_button_rect();
240        let x_text_x = close.x + close.w * 0.5 - 4.0;
241        let x_text_y = close.y + close.h * 0.5 + 5.0;
242        canvas.draw_text_run_weighted(
243            [x_text_x, x_text_y],
244            "\u{2715}".to_string(),
245            14.0,
246            400.0,
247            ColorLinPremul::from_srgba_u8([100, 100, 100, 255]),
248            z + 3,
249        );
250
251        // 4. Title
252        canvas.draw_text_run_weighted(
253            [panel.x + 20.0, panel.y + 30.0],
254            self.title.clone(),
255            self.title_size,
256            600.0,
257            self.title_color,
258            z + 2,
259        );
260
261        // 5. Content (multi-line)
262        let line_height = self.content_size * 1.4;
263        let content_y = panel.y + 70.0;
264        for (i, line) in self.content.split('\n').enumerate() {
265            canvas.draw_text_run_weighted(
266                [panel.x + 20.0, content_y + i as f32 * line_height],
267                line.to_string(),
268                self.content_size,
269                400.0,
270                self.content_color,
271                z + 2,
272            );
273        }
274
275        // 6. Buttons
276        for (i, (button, btn_rect)) in self.buttons.iter().zip(self.button_rects()).enumerate() {
277            let (bg, fg) = if button.primary {
278                (
279                    ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
280                    ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
281                )
282            } else {
283                (
284                    ColorLinPremul::from_srgba_u8([240, 240, 240, 255]),
285                    ColorLinPremul::from_srgba_u8([60, 60, 60, 255]),
286                )
287            };
288
289            let btn_rrect = RoundedRect {
290                rect: btn_rect,
291                radii: RoundedRadii {
292                    tl: 6.0,
293                    tr: 6.0,
294                    br: 6.0,
295                    bl: 6.0,
296                },
297            };
298            canvas.rounded_rect(btn_rrect, Brush::Solid(bg), z + 3 + i as i32);
299
300            let text_w = button.label.len() as f32 * self.button_label_size * 0.5;
301            let tx = btn_rect.x + (btn_rect.w - text_w) * 0.5;
302            let ty = btn_rect.y + btn_rect.h * 0.5 + self.button_label_size * 0.35;
303            canvas.draw_text_run_weighted(
304                [tx, ty],
305                button.label.clone(),
306                self.button_label_size,
307                600.0,
308                fg,
309                z + 4 + i as i32,
310            );
311        }
312    }
313
314    fn focus_id(&self) -> Option<FocusId> {
315        Some(self.focus_id)
316    }
317}
318
319// ---------------------------------------------------------------------------
320// EventHandler trait
321// ---------------------------------------------------------------------------
322
323impl EventHandler for Modal {
324    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
325        if !self.visible {
326            return EventResult::Ignored;
327        }
328        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
329            return EventResult::Ignored;
330        }
331        // Modal captures all clicks when visible.
332        let _result = self.handle_click(event.x, event.y);
333        EventResult::Handled
334    }
335
336    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
337        if !self.visible || event.state != ElementState::Pressed {
338            return EventResult::Ignored;
339        }
340        match event.key {
341            KeyCode::Escape => EventResult::Handled,
342            KeyCode::Enter => {
343                if self.buttons.iter().any(|b| b.primary) {
344                    EventResult::Handled
345                } else {
346                    EventResult::Ignored
347                }
348            }
349            _ => EventResult::Ignored,
350        }
351    }
352
353    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
354        if self.visible {
355            EventResult::Handled
356        } else {
357            EventResult::Ignored
358        }
359    }
360
361    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
362        if self.visible {
363            EventResult::Handled
364        } else {
365            EventResult::Ignored
366        }
367    }
368
369    fn is_focused(&self) -> bool {
370        self.visible
371    }
372
373    fn set_focused(&mut self, _focused: bool) {}
374
375    fn contains_point(&self, _x: f32, _y: f32) -> bool {
376        // Modal captures all input when visible.
377        self.visible
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Tests
383// ---------------------------------------------------------------------------
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn viewport() -> Rect {
390        Rect {
391            x: 0.0,
392            y: 0.0,
393            w: 800.0,
394            h: 600.0,
395        }
396    }
397
398    #[test]
399    fn modal_panel_centered() {
400        let m = Modal::new(viewport(), "Title", "Body", vec![]);
401        let p = m.panel_rect();
402        let cx = p.x + p.w * 0.5;
403        let cy = p.y + p.h * 0.5;
404        assert!((cx - 400.0).abs() < 1.0);
405        assert!((cy - 300.0).abs() < 1.0);
406    }
407
408    #[test]
409    fn modal_click_background() {
410        let m = Modal::new(viewport(), "T", "C", vec![]);
411        let result = m.handle_click(0.0, 0.0);
412        assert_eq!(result, ModalClickResult::Background);
413    }
414
415    #[test]
416    fn modal_click_close_button() {
417        let m = Modal::new(viewport(), "T", "C", vec![]);
418        let close = m.close_button_rect();
419        let result = m.handle_click(close.x + 5.0, close.y + 5.0);
420        assert_eq!(result, ModalClickResult::CloseButton);
421    }
422
423    #[test]
424    fn modal_click_action_button() {
425        let m = Modal::new(
426            viewport(),
427            "T",
428            "C",
429            vec![ModalButton::new("Cancel"), ModalButton::primary("OK")],
430        );
431        let rects = m.button_rects();
432        assert_eq!(rects.len(), 2);
433        let r = rects[1];
434        let result = m.handle_click(r.x + 5.0, r.y + 5.0);
435        assert_eq!(result, ModalClickResult::Button(1));
436    }
437
438    #[test]
439    fn modal_captures_input_when_visible() {
440        let m = Modal::new(viewport(), "T", "C", vec![]);
441        assert!(m.contains_point(0.0, 0.0));
442    }
443
444    #[test]
445    fn modal_ignores_when_hidden() {
446        let mut m = Modal::new(viewport(), "T", "C", vec![]);
447        m.visible = false;
448        assert!(!m.contains_point(400.0, 300.0));
449    }
450
451    #[test]
452    fn modal_escape_handled() {
453        let mut m = Modal::new(viewport(), "T", "C", vec![]);
454        let evt = KeyboardEvent {
455            key: KeyCode::Escape,
456            state: ElementState::Pressed,
457            modifiers: Default::default(),
458            text: None,
459        };
460        assert_eq!(m.handle_keyboard(&evt), EventResult::Handled);
461    }
462}