ratatui_toolkit/dialog/
mod.rs

1//! Dialog component
2//!
3//! Provides modal dialog widgets with customizable buttons and styles.
4
5use ratatui::{
6    buffer::Buffer,
7    layout::{Alignment, Constraint, Direction, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap},
11};
12
13/// Dialog type
14///
15/// Represents different types of dialogs with associated visual styles.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum DialogType {
18    /// Informational dialog (cyan border)
19    Info,
20    /// Success dialog (green border)
21    Success,
22    /// Warning dialog (yellow border)
23    Warning,
24    /// Error dialog (red border)
25    Error,
26    /// Confirmation dialog (blue border)
27    Confirm,
28}
29
30/// A dialog/modal widget that overlays content
31///
32/// Dialogs are centered modals that display a message and buttons.
33/// They support different visual styles (info, success, warning, error, confirm)
34/// and can handle mouse clicks on buttons.
35pub struct Dialog<'a> {
36    /// Dialog title
37    title: &'a str,
38    /// Dialog message
39    message: &'a str,
40    /// Dialog type
41    dialog_type: DialogType,
42    /// Buttons to show
43    buttons: Vec<&'a str>,
44    /// Selected button index
45    selected_button: usize,
46    /// Width percentage (0.0 to 1.0)
47    width_percent: f32,
48    /// Height percentage (0.0 to 1.0)
49    height_percent: f32,
50    /// Style for the dialog
51    style: Style,
52    /// Style for selected button
53    button_selected_style: Style,
54    /// Style for unselected button
55    button_style: Style,
56    /// Areas for buttons (for click detection)
57    button_areas: Vec<Rect>,
58}
59
60impl<'a> Dialog<'a> {
61    /// Create a new dialog with default styling
62    pub fn new(title: &'a str, message: &'a str) -> Self {
63        Self {
64            title,
65            message,
66            dialog_type: DialogType::Info,
67            buttons: vec!["OK"],
68            selected_button: 0,
69            width_percent: 0.6,
70            height_percent: 0.4,
71            style: Style::default().fg(Color::White).bg(Color::Black),
72            button_selected_style: Style::default()
73                .fg(Color::Black)
74                .bg(Color::Cyan)
75                .add_modifier(Modifier::BOLD),
76            button_style: Style::default().fg(Color::White).bg(Color::DarkGray),
77            button_areas: Vec::new(),
78        }
79    }
80
81    /// Set dialog type
82    pub fn dialog_type(mut self, dialog_type: DialogType) -> Self {
83        self.dialog_type = dialog_type;
84        self
85    }
86
87    /// Set buttons
88    pub fn buttons(mut self, buttons: Vec<&'a str>) -> Self {
89        self.buttons = buttons;
90        self
91    }
92
93    /// Set width percentage
94    pub fn width_percent(mut self, percent: f32) -> Self {
95        self.width_percent = percent.clamp(0.1, 1.0);
96        self
97    }
98
99    /// Set height percentage
100    pub fn height_percent(mut self, percent: f32) -> Self {
101        self.height_percent = percent.clamp(0.1, 1.0);
102        self
103    }
104
105    /// Select next button
106    pub fn select_next_button(&mut self) {
107        if !self.buttons.is_empty() && self.selected_button < self.buttons.len() - 1 {
108            self.selected_button += 1;
109        }
110    }
111
112    /// Select previous button
113    pub fn select_previous_button(&mut self) {
114        if self.selected_button > 0 {
115            self.selected_button -= 1;
116        }
117    }
118
119    /// Get selected button index
120    pub fn get_selected_button(&self) -> usize {
121        self.selected_button
122    }
123
124    /// Get selected button text
125    pub fn get_selected_button_text(&self) -> Option<&str> {
126        self.buttons.get(self.selected_button).copied()
127    }
128
129    /// Handle click on buttons
130    pub fn handle_click(&self, column: u16, row: u16) -> Option<usize> {
131        for (idx, area) in self.button_areas.iter().enumerate() {
132            if column >= area.x
133                && column < area.x + area.width
134                && row >= area.y
135                && row < area.y + area.height
136            {
137                return Some(idx);
138            }
139        }
140        None
141    }
142
143    /// Create a confirmation dialog
144    pub fn confirm(title: &'a str, message: &'a str) -> Self {
145        Self::new(title, message)
146            .dialog_type(DialogType::Confirm)
147            .buttons(vec!["Yes", "No"])
148    }
149
150    /// Create an info dialog
151    pub fn info(title: &'a str, message: &'a str) -> Self {
152        Self::new(title, message).dialog_type(DialogType::Info)
153    }
154
155    /// Create a success dialog
156    pub fn success(title: &'a str, message: &'a str) -> Self {
157        Self::new(title, message).dialog_type(DialogType::Success)
158    }
159
160    /// Create a warning dialog
161    pub fn warning(title: &'a str, message: &'a str) -> Self {
162        Self::new(title, message).dialog_type(DialogType::Warning)
163    }
164
165    /// Create an error dialog
166    pub fn error(title: &'a str, message: &'a str) -> Self {
167        Self::new(title, message).dialog_type(DialogType::Error)
168    }
169
170    /// Get border color based on dialog type
171    fn get_border_color(&self) -> Color {
172        match self.dialog_type {
173            DialogType::Info => Color::Cyan,
174            DialogType::Success => Color::Green,
175            DialogType::Warning => Color::Yellow,
176            DialogType::Error => Color::Red,
177            DialogType::Confirm => Color::Blue,
178        }
179    }
180}
181
182impl Widget for Dialog<'_> {
183    fn render(mut self, area: Rect, buf: &mut Buffer) {
184        let dialog_width = (area.width as f32 * self.width_percent) as u16;
185        let dialog_height = (area.height as f32 * self.height_percent) as u16;
186        let dialog_x = (area.width.saturating_sub(dialog_width)) / 2;
187        let dialog_y = (area.height.saturating_sub(dialog_height)) / 2;
188
189        let dialog_area = Rect {
190            x: area.x + dialog_x,
191            y: area.y + dialog_y,
192            width: dialog_width,
193            height: dialog_height,
194        };
195
196        Clear.render(dialog_area, buf);
197
198        let block = Block::default()
199            .title(self.title)
200            .borders(Borders::ALL)
201            .border_type(BorderType::Rounded)
202            .border_style(Style::default().fg(self.get_border_color()))
203            .style(self.style);
204
205        let inner = block.inner(dialog_area);
206        block.render(dialog_area, buf);
207
208        let chunks = Layout::default()
209            .direction(Direction::Vertical)
210            .constraints([Constraint::Min(3), Constraint::Length(3)])
211            .split(inner);
212
213        let message = Paragraph::new(self.message)
214            .style(self.style)
215            .alignment(Alignment::Center)
216            .wrap(Wrap { trim: true });
217        message.render(chunks[0], buf);
218
219        self.button_areas.clear();
220
221        if !self.buttons.is_empty() {
222            let total_button_width: usize = self.buttons.iter().map(|b| b.len() + 4).sum();
223            let button_area_width = chunks[1].width as usize;
224            let start_x = if total_button_width < button_area_width {
225                chunks[1].x + ((button_area_width - total_button_width) / 2) as u16
226            } else {
227                chunks[1].x
228            };
229
230            let mut x = start_x;
231            let y = chunks[1].y + 1;
232
233            for (idx, button_text) in self.buttons.iter().enumerate() {
234                let button_width = button_text.len() as u16 + 2;
235                let style = if idx == self.selected_button {
236                    self.button_selected_style
237                } else {
238                    self.button_style
239                };
240
241                let button_area = Rect {
242                    x,
243                    y,
244                    width: button_width,
245                    height: 1,
246                };
247
248                self.button_areas.push(button_area);
249
250                for bx in x..x + button_width {
251                    if let Some(cell) = buf.cell_mut((bx, y)) {
252                        cell.set_style(style);
253                    }
254                }
255
256                let button_line =
257                    Line::from(vec![Span::styled(format!(" {} ", button_text), style)]);
258
259                buf.set_line(x, y, &button_line, button_width);
260                x += button_width + 2;
261            }
262        }
263    }
264}
265
266/// Mutable widget variant
267pub struct DialogWidget<'a> {
268    dialog: &'a mut Dialog<'a>,
269}
270
271impl<'a> DialogWidget<'a> {
272    pub fn new(dialog: &'a mut Dialog<'a>) -> Self {
273        Self { dialog }
274    }
275}
276
277impl Widget for DialogWidget<'_> {
278    fn render(self, area: Rect, buf: &mut Buffer) {
279        let dialog_width = (area.width as f32 * self.dialog.width_percent) as u16;
280        let dialog_height = (area.height as f32 * self.dialog.height_percent) as u16;
281        let dialog_x = (area.width.saturating_sub(dialog_width)) / 2;
282        let dialog_y = (area.height.saturating_sub(dialog_height)) / 2;
283
284        let dialog_area = Rect {
285            x: area.x + dialog_x,
286            y: area.y + dialog_y,
287            width: dialog_width,
288            height: dialog_height,
289        };
290
291        Clear.render(dialog_area, buf);
292
293        let block = Block::default()
294            .title(self.dialog.title)
295            .borders(Borders::ALL)
296            .border_type(BorderType::Rounded)
297            .border_style(Style::default().fg(self.dialog.get_border_color()))
298            .style(self.dialog.style);
299
300        let inner = block.inner(dialog_area);
301        block.render(dialog_area, buf);
302
303        let chunks = Layout::default()
304            .direction(Direction::Vertical)
305            .constraints([Constraint::Min(3), Constraint::Length(3)])
306            .split(inner);
307
308        let message = Paragraph::new(self.dialog.message)
309            .style(self.dialog.style)
310            .alignment(Alignment::Center)
311            .wrap(Wrap { trim: true });
312        message.render(chunks[0], buf);
313
314        self.dialog.button_areas.clear();
315
316        if !self.dialog.buttons.is_empty() {
317            let total_button_width: usize = self.dialog.buttons.iter().map(|b| b.len() + 4).sum();
318            let button_area_width = chunks[1].width as usize;
319            let start_x = if total_button_width < button_area_width {
320                chunks[1].x + ((button_area_width - total_button_width) / 2) as u16
321            } else {
322                chunks[1].x
323            };
324
325            let mut x = start_x;
326            let y = chunks[1].y + 1;
327
328            for (idx, button_text) in self.dialog.buttons.iter().enumerate() {
329                let button_width = button_text.len() as u16 + 2;
330                let style = if idx == self.dialog.selected_button {
331                    self.dialog.button_selected_style
332                } else {
333                    self.dialog.button_style
334                };
335
336                let button_area = Rect {
337                    x,
338                    y,
339                    width: button_width,
340                    height: 1,
341                };
342
343                self.dialog.button_areas.push(button_area);
344
345                for bx in x..x + button_width {
346                    if let Some(cell) = buf.cell_mut((bx, y)) {
347                        cell.set_style(style);
348                    }
349                }
350
351                let button_line =
352                    Line::from(vec![Span::styled(format!(" {} ", button_text), style)]);
353
354                buf.set_line(x, y, &button_line, button_width);
355                x += button_width + 2;
356            }
357        }
358    }
359}