revue/widget/
modal.rs

1//! Modal/Dialog widget for displaying overlays
2
3use super::traits::{RenderContext, View, WidgetProps};
4use crate::render::Cell;
5use crate::style::Color;
6use crate::{impl_props_builders, impl_styled_view};
7
8/// Button configuration for modal dialogs
9///
10/// This is distinct from the interactive `Button` widget.
11/// `ModalButton` configures the appearance and label of buttons
12/// shown at the bottom of modal dialogs.
13#[derive(Clone)]
14pub struct ModalButton {
15    /// Button label
16    pub label: String,
17    /// Button style
18    pub style: ModalButtonStyle,
19}
20
21/// Style preset for modal buttons
22#[derive(Clone, Copy, Default)]
23pub enum ModalButtonStyle {
24    /// Default neutral button
25    #[default]
26    Default,
27    /// Primary action button (highlighted)
28    Primary,
29    /// Danger/destructive action button
30    Danger,
31}
32
33impl ModalButton {
34    /// Create a new button with default style
35    pub fn new(label: impl Into<String>) -> Self {
36        Self {
37            label: label.into(),
38            style: ModalButtonStyle::Default,
39        }
40    }
41
42    /// Create a primary action button
43    pub fn primary(label: impl Into<String>) -> Self {
44        Self {
45            label: label.into(),
46            style: ModalButtonStyle::Primary,
47        }
48    }
49
50    /// Create a danger/destructive action button
51    pub fn danger(label: impl Into<String>) -> Self {
52        Self {
53            label: label.into(),
54            style: ModalButtonStyle::Danger,
55        }
56    }
57}
58
59/// A modal dialog widget
60pub struct Modal {
61    title: String,
62    /// Text content (for simple messages)
63    content: Vec<String>,
64    /// Child widget content (takes precedence over text content)
65    body: Option<Box<dyn View>>,
66    buttons: Vec<ModalButton>,
67    selected_button: usize,
68    visible: bool,
69    width: u16,
70    height: Option<u16>,
71    title_fg: Option<Color>,
72    border_fg: Option<Color>,
73    props: WidgetProps,
74}
75
76impl Modal {
77    /// Create a new modal dialog
78    pub fn new() -> Self {
79        Self {
80            title: String::new(),
81            content: Vec::new(),
82            body: None,
83            buttons: Vec::new(),
84            selected_button: 0,
85            visible: false,
86            width: 40,
87            height: None,
88            title_fg: Some(Color::WHITE),
89            border_fg: Some(Color::WHITE),
90            props: WidgetProps::new(),
91        }
92    }
93
94    /// Set modal title
95    pub fn title(mut self, title: impl Into<String>) -> Self {
96        self.title = title.into();
97        self
98    }
99
100    /// Set modal content
101    pub fn content(mut self, content: impl Into<String>) -> Self {
102        self.content = content.into().lines().map(|s| s.to_string()).collect();
103        self
104    }
105
106    /// Add a line to content
107    pub fn line(mut self, line: impl Into<String>) -> Self {
108        self.content.push(line.into());
109        self
110    }
111
112    /// Set buttons
113    pub fn buttons(mut self, buttons: Vec<ModalButton>) -> Self {
114        self.buttons = buttons;
115        self
116    }
117
118    /// Add OK button
119    pub fn ok(mut self) -> Self {
120        self.buttons.push(ModalButton::primary("OK"));
121        self
122    }
123
124    /// Add Cancel button
125    pub fn cancel(mut self) -> Self {
126        self.buttons.push(ModalButton::new("Cancel"));
127        self
128    }
129
130    /// Add OK and Cancel buttons
131    pub fn ok_cancel(mut self) -> Self {
132        self.buttons.push(ModalButton::primary("OK"));
133        self.buttons.push(ModalButton::new("Cancel"));
134        self
135    }
136
137    /// Add Yes and No buttons
138    pub fn yes_no(mut self) -> Self {
139        self.buttons.push(ModalButton::primary("Yes"));
140        self.buttons.push(ModalButton::new("No"));
141        self
142    }
143
144    /// Add Yes, No, and Cancel buttons
145    pub fn yes_no_cancel(mut self) -> Self {
146        self.buttons.push(ModalButton::primary("Yes"));
147        self.buttons.push(ModalButton::new("No"));
148        self.buttons.push(ModalButton::new("Cancel"));
149        self
150    }
151
152    /// Set modal width
153    pub fn width(mut self, width: u16) -> Self {
154        self.width = width;
155        self
156    }
157
158    /// Set modal height (None = auto)
159    pub fn height(mut self, height: u16) -> Self {
160        self.height = Some(height);
161        self
162    }
163
164    /// Set a child widget as body content
165    ///
166    /// When a body widget is set, it takes precedence over text content.
167    /// The widget will be rendered inside the modal's content area.
168    ///
169    /// # Example
170    ///
171    /// ```rust,ignore
172    /// use revue::prelude::*;
173    ///
174    /// let modal = Modal::new()
175    ///     .title("User Form")
176    ///     .body(
177    ///         vstack()
178    ///             .gap(1)
179    ///             .child(Input::new().placeholder("Name"))
180    ///             .child(Input::new().placeholder("Email"))
181    ///     )
182    ///     .ok_cancel();
183    /// ```
184    pub fn body(mut self, widget: impl View + 'static) -> Self {
185        self.body = Some(Box::new(widget));
186        self
187    }
188
189    /// Set title color
190    pub fn title_fg(mut self, color: Color) -> Self {
191        self.title_fg = Some(color);
192        self
193    }
194
195    /// Set border color
196    pub fn border_fg(mut self, color: Color) -> Self {
197        self.border_fg = Some(color);
198        self
199    }
200
201    /// Show the modal
202    pub fn show(&mut self) {
203        self.visible = true;
204    }
205
206    /// Hide the modal
207    pub fn hide(&mut self) {
208        self.visible = false;
209    }
210
211    /// Toggle visibility
212    pub fn toggle(&mut self) {
213        self.visible = !self.visible;
214    }
215
216    /// Check if modal is visible
217    pub fn is_visible(&self) -> bool {
218        self.visible
219    }
220
221    /// Get selected button index
222    pub fn selected_button(&self) -> usize {
223        self.selected_button
224    }
225
226    /// Select next button
227    pub fn next_button(&mut self) {
228        if !self.buttons.is_empty() {
229            self.selected_button = (self.selected_button + 1) % self.buttons.len();
230        }
231    }
232
233    /// Select previous button
234    pub fn prev_button(&mut self) {
235        if !self.buttons.is_empty() {
236            self.selected_button = self
237                .selected_button
238                .checked_sub(1)
239                .unwrap_or(self.buttons.len() - 1);
240        }
241    }
242
243    /// Handle key input, returns Some(button_index) if button confirmed
244    pub fn handle_key(&mut self, key: &crate::event::Key) -> Option<usize> {
245        use crate::event::Key;
246
247        match key {
248            Key::Enter | Key::Char(' ') => {
249                if !self.buttons.is_empty() {
250                    Some(self.selected_button)
251                } else {
252                    None
253                }
254            }
255            Key::Left | Key::Char('h') => {
256                self.prev_button();
257                None
258            }
259            Key::Right | Key::Char('l') => {
260                self.next_button();
261                None
262            }
263            Key::Tab => {
264                self.next_button();
265                None
266            }
267            Key::Escape => {
268                self.hide();
269                None
270            }
271            _ => None,
272        }
273    }
274
275    /// Create alert dialog
276    pub fn alert(title: impl Into<String>, message: impl Into<String>) -> Self {
277        Self::new().title(title).content(message).ok()
278    }
279
280    /// Create confirmation dialog
281    pub fn confirm(title: impl Into<String>, message: impl Into<String>) -> Self {
282        Self::new().title(title).content(message).yes_no()
283    }
284
285    /// Create error dialog
286    pub fn error(message: impl Into<String>) -> Self {
287        Self::new()
288            .title("Error")
289            .title_fg(Color::RED)
290            .border_fg(Color::RED)
291            .content(message)
292            .ok()
293    }
294
295    /// Create warning dialog
296    pub fn warning(message: impl Into<String>) -> Self {
297        Self::new()
298            .title("Warning")
299            .title_fg(Color::YELLOW)
300            .border_fg(Color::YELLOW)
301            .content(message)
302            .ok()
303    }
304
305    /// Calculate required height
306    fn required_height(&self) -> u16 {
307        // If height is explicitly set, use it
308        if let Some(h) = self.height {
309            return h;
310        }
311
312        // For body widget, use a default content height of 5 lines
313        let content_lines = if self.body.is_some() {
314            5u16
315        } else {
316            self.content.len() as u16
317        };
318
319        let button_line = if self.buttons.is_empty() { 0 } else { 1 };
320        // top border + title + title separator + content + padding + buttons + bottom border
321        3 + content_lines + 1 + button_line + 1
322    }
323}
324
325impl Default for Modal {
326    fn default() -> Self {
327        Self::new()
328    }
329}
330
331impl View for Modal {
332    fn render(&self, ctx: &mut RenderContext) {
333        if !self.visible {
334            return;
335        }
336
337        let area = ctx.area;
338        let modal_width = self.width.min(area.width.saturating_sub(4));
339        let modal_height = self.required_height().min(area.height.saturating_sub(2));
340
341        // Center the modal
342        let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
343        let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
344
345        // Draw border
346        self.render_border(ctx, x, y, modal_width, modal_height);
347
348        // Draw title
349        if !self.title.is_empty() {
350            let title_x = x + 2;
351            let title_width = (modal_width - 4) as usize;
352            let title: String = self.title.chars().take(title_width).collect();
353
354            for (i, ch) in title.chars().enumerate() {
355                let mut cell = Cell::new(ch);
356                cell.fg = self.title_fg;
357                cell.modifier |= crate::render::Modifier::BOLD;
358                ctx.buffer.set(title_x + i as u16, y + 1, cell);
359            }
360
361            // Title separator
362            for dx in 1..(modal_width - 1) {
363                ctx.buffer.set(x + dx, y + 2, Cell::new('─'));
364            }
365            ctx.buffer.set(x, y + 2, Cell::new('├'));
366            ctx.buffer.set(x + modal_width - 1, y + 2, Cell::new('┤'));
367        }
368
369        // Draw content
370        let content_y = y + 3;
371        let content_width = modal_width.saturating_sub(4);
372        let content_height = modal_height.saturating_sub(6); // title + separator + padding + buttons + borders
373
374        if let Some(ref body_widget) = self.body {
375            // Render child widget
376            let content_area =
377                crate::layout::Rect::new(x + 2, content_y, content_width, content_height);
378            let mut body_ctx = RenderContext::new(ctx.buffer, content_area);
379            body_widget.render(&mut body_ctx);
380        } else {
381            // Render text content
382            for (i, line) in self.content.iter().enumerate() {
383                let cy = content_y + i as u16;
384                if cy >= y + modal_height - 2 {
385                    break;
386                }
387                let truncated: String = line.chars().take(content_width as usize).collect();
388                for (j, ch) in truncated.chars().enumerate() {
389                    ctx.buffer.set(x + 2 + j as u16, cy, Cell::new(ch));
390                }
391            }
392        }
393
394        // Draw buttons
395        if !self.buttons.is_empty() {
396            let button_y = y + modal_height - 2;
397            let total_button_width: usize = self
398                .buttons
399                .iter()
400                .map(|b| b.label.len() + 4) // [ label ]
401                .sum::<usize>()
402                + (self.buttons.len() - 1) * 2; // spacing
403
404            let start_x = x + (modal_width - total_button_width as u16) / 2;
405            let mut bx = start_x;
406
407            for (i, button) in self.buttons.iter().enumerate() {
408                let is_selected = i == self.selected_button;
409                let button_text = format!("[ {} ]", button.label);
410
411                let (fg, bg) = if is_selected {
412                    match button.style {
413                        ModalButtonStyle::Primary => (Some(Color::WHITE), Some(Color::BLUE)),
414                        ModalButtonStyle::Danger => (Some(Color::WHITE), Some(Color::RED)),
415                        ModalButtonStyle::Default => (Some(Color::BLACK), Some(Color::WHITE)),
416                    }
417                } else {
418                    (None, None)
419                };
420
421                for (j, ch) in button_text.chars().enumerate() {
422                    let mut cell = Cell::new(ch);
423                    cell.fg = fg;
424                    cell.bg = bg;
425                    ctx.buffer.set(bx + j as u16, button_y, cell);
426                }
427
428                bx += button_text.len() as u16 + 2;
429            }
430        }
431    }
432
433    crate::impl_view_meta!("Modal");
434}
435
436impl Modal {
437    fn render_border(&self, ctx: &mut RenderContext, x: u16, y: u16, width: u16, height: u16) {
438        // Clear interior with spaces
439        for dy in 1..height.saturating_sub(1) {
440            for dx in 1..width.saturating_sub(1) {
441                ctx.buffer.set(x + dx, y + dy, Cell::new(' '));
442            }
443        }
444
445        // Top border
446        let mut corner = Cell::new('┌');
447        corner.fg = self.border_fg;
448        ctx.buffer.set(x, y, corner);
449
450        for dx in 1..(width - 1) {
451            let mut cell = Cell::new('─');
452            cell.fg = self.border_fg;
453            ctx.buffer.set(x + dx, y, cell);
454        }
455
456        let mut corner = Cell::new('┐');
457        corner.fg = self.border_fg;
458        ctx.buffer.set(x + width - 1, y, corner);
459
460        // Sides
461        for dy in 1..(height - 1) {
462            let mut cell = Cell::new('│');
463            cell.fg = self.border_fg;
464            ctx.buffer.set(x, y + dy, cell);
465            ctx.buffer.set(x + width - 1, y + dy, cell);
466        }
467
468        // Bottom border
469        let mut corner = Cell::new('└');
470        corner.fg = self.border_fg;
471        ctx.buffer.set(x, y + height - 1, corner);
472
473        for dx in 1..(width - 1) {
474            let mut cell = Cell::new('─');
475            cell.fg = self.border_fg;
476            ctx.buffer.set(x + dx, y + height - 1, cell);
477        }
478
479        let mut corner = Cell::new('┘');
480        corner.fg = self.border_fg;
481        ctx.buffer.set(x + width - 1, y + height - 1, corner);
482    }
483}
484
485/// Helper function to create a modal
486pub fn modal() -> Modal {
487    Modal::new()
488}
489
490impl_styled_view!(Modal);
491impl_props_builders!(Modal);
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::layout::Rect;
497    use crate::render::Buffer;
498
499    #[test]
500    fn test_modal_new() {
501        let m = Modal::new();
502        assert!(!m.is_visible());
503        assert!(m.title.is_empty());
504        assert!(m.content.is_empty());
505        assert!(m.buttons.is_empty());
506    }
507
508    #[test]
509    fn test_modal_builder() {
510        let m = Modal::new()
511            .title("Test")
512            .content("Hello\nWorld")
513            .ok_cancel();
514
515        assert_eq!(m.title, "Test");
516        assert_eq!(m.content.len(), 2);
517        assert_eq!(m.buttons.len(), 2);
518    }
519
520    #[test]
521    fn test_modal_visibility() {
522        let mut m = Modal::new();
523        assert!(!m.is_visible());
524
525        m.show();
526        assert!(m.is_visible());
527
528        m.hide();
529        assert!(!m.is_visible());
530
531        m.toggle();
532        assert!(m.is_visible());
533    }
534
535    #[test]
536    fn test_modal_button_navigation() {
537        let mut m = Modal::new().ok_cancel();
538
539        assert_eq!(m.selected_button(), 0);
540
541        m.next_button();
542        assert_eq!(m.selected_button(), 1);
543
544        m.next_button(); // Wraps around
545        assert_eq!(m.selected_button(), 0);
546
547        m.prev_button(); // Wraps around
548        assert_eq!(m.selected_button(), 1);
549    }
550
551    #[test]
552    fn test_modal_handle_key() {
553        use crate::event::Key;
554
555        let mut m = Modal::new().yes_no();
556        m.show();
557
558        // Navigate buttons
559        m.handle_key(&Key::Right);
560        assert_eq!(m.selected_button(), 1);
561
562        m.handle_key(&Key::Left);
563        assert_eq!(m.selected_button(), 0);
564
565        // Confirm selection
566        let result = m.handle_key(&Key::Enter);
567        assert_eq!(result, Some(0));
568
569        // Escape closes
570        m.handle_key(&Key::Escape);
571        assert!(!m.is_visible());
572    }
573
574    #[test]
575    fn test_modal_presets() {
576        let alert = Modal::alert("Title", "Message");
577        assert_eq!(alert.title, "Title");
578        assert_eq!(alert.buttons.len(), 1);
579
580        let confirm = Modal::confirm("Title", "Question?");
581        assert_eq!(confirm.buttons.len(), 2);
582
583        let error = Modal::error("Something went wrong");
584        assert_eq!(error.title, "Error");
585    }
586
587    #[test]
588    fn test_modal_render_hidden() {
589        let mut buffer = Buffer::new(80, 24);
590        let area = Rect::new(0, 0, 80, 24);
591        let mut ctx = RenderContext::new(&mut buffer, area);
592
593        let m = Modal::new().title("Test");
594        m.render(&mut ctx);
595
596        // Hidden modal shouldn't render anything special
597        assert_eq!(buffer.get(0, 0).unwrap().symbol, ' ');
598    }
599
600    #[test]
601    fn test_modal_render_visible() {
602        let mut buffer = Buffer::new(80, 24);
603        let area = Rect::new(0, 0, 80, 24);
604        let mut ctx = RenderContext::new(&mut buffer, area);
605
606        let mut m = Modal::new().title("Test Dialog").content("Hello").ok();
607        m.show();
608        m.render(&mut ctx);
609
610        // Modal should render centered - check for border characters
611        // The exact position depends on centering calculation
612        let center_x = (80 - 40) / 2;
613        let center_y = (24 - m.required_height()) / 2;
614
615        assert_eq!(buffer.get(center_x, center_y).unwrap().symbol, '┌');
616    }
617
618    #[test]
619    fn test_modal_button_styles() {
620        let btn = ModalButton::new("Test");
621        assert!(matches!(btn.style, ModalButtonStyle::Default));
622
623        let btn = ModalButton::primary("OK");
624        assert!(matches!(btn.style, ModalButtonStyle::Primary));
625
626        let btn = ModalButton::danger("Delete");
627        assert!(matches!(btn.style, ModalButtonStyle::Danger));
628    }
629
630    #[test]
631    fn test_modal_helper() {
632        let m = modal().title("Quick").ok();
633
634        assert_eq!(m.title, "Quick");
635    }
636
637    #[test]
638    fn test_modal_with_body() {
639        use crate::widget::Text;
640
641        let m = Modal::new()
642            .title("Form")
643            .body(Text::new("Custom content"))
644            .height(10)
645            .ok();
646
647        assert!(m.body.is_some());
648        assert_eq!(m.height, Some(10));
649    }
650
651    #[test]
652    fn test_modal_body_render() {
653        use crate::widget::Text;
654
655        let mut buffer = Buffer::new(80, 24);
656        let area = Rect::new(0, 0, 80, 24);
657        let mut ctx = RenderContext::new(&mut buffer, area);
658
659        let mut m = Modal::new()
660            .title("Body Test")
661            .body(Text::new("Widget content"))
662            .width(50)
663            .height(12)
664            .ok();
665        m.show();
666        m.render(&mut ctx);
667
668        // Modal with body should render
669        let center_x = (80 - 50) / 2;
670        let center_y = (24 - 12) / 2;
671        assert_eq!(buffer.get(center_x, center_y).unwrap().symbol, '┌');
672    }
673}