Skip to main content

mockforge_tui/widgets/
confirm.rs

1//! Confirmation dialog widget.
2
3use crossterm::event::{KeyCode, KeyEvent};
4use ratatui::{
5    layout::{Constraint, Flex, Layout, Rect},
6    text::{Line, Span},
7    widgets::{Block, Borders, Clear, Paragraph},
8    Frame,
9};
10
11use crate::theme::Theme;
12
13/// A simple yes/no confirmation dialog.
14#[derive(Debug)]
15pub struct ConfirmDialog {
16    pub visible: bool,
17    pub title: String,
18    pub message: String,
19    pub selected_yes: bool,
20}
21
22impl Default for ConfirmDialog {
23    fn default() -> Self {
24        Self {
25            visible: false,
26            title: String::new(),
27            message: String::new(),
28            selected_yes: false,
29        }
30    }
31}
32
33impl ConfirmDialog {
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Show the dialog with a custom title and message.
39    pub fn show(&mut self, title: impl Into<String>, message: impl Into<String>) {
40        self.title = title.into();
41        self.message = message.into();
42        self.selected_yes = false;
43        self.visible = true;
44    }
45
46    /// Hide the dialog.
47    pub fn hide(&mut self) {
48        self.visible = false;
49    }
50
51    /// Handle a key event. Returns `Some(true)` for yes, `Some(false)` for no,
52    /// `None` if still undecided.
53    pub fn handle_key(&mut self, key: KeyEvent) -> Option<bool> {
54        if !self.visible {
55            return None;
56        }
57        match key.code {
58            KeyCode::Left | KeyCode::Char('h') => {
59                self.selected_yes = true;
60                None
61            }
62            KeyCode::Right | KeyCode::Char('l') => {
63                self.selected_yes = false;
64                None
65            }
66            KeyCode::Char('y') => {
67                self.hide();
68                Some(true)
69            }
70            KeyCode::Char('n') | KeyCode::Esc => {
71                self.hide();
72                Some(false)
73            }
74            KeyCode::Enter => {
75                let result = self.selected_yes;
76                self.hide();
77                Some(result)
78            }
79            _ => None,
80        }
81    }
82
83    /// Render the dialog centred on screen.
84    pub fn render(&self, frame: &mut Frame) {
85        if !self.visible {
86            return;
87        }
88
89        let area = centered_rect(40, 20, frame.area());
90        frame.render_widget(Clear, area);
91
92        let yes_style = if self.selected_yes {
93            Theme::highlight()
94        } else {
95            Theme::dim()
96        };
97        let no_style = if self.selected_yes {
98            Theme::dim()
99        } else {
100            Theme::highlight()
101        };
102
103        let lines = vec![
104            Line::from(""),
105            Line::from(Span::styled(&self.message, Theme::base())),
106            Line::from(""),
107            Line::from(vec![
108                Span::styled("  [ Yes ]  ", yes_style),
109                Span::raw("  "),
110                Span::styled("  [ No ]  ", no_style),
111            ]),
112        ];
113
114        let block = Block::default()
115            .title(format!(" {} ", self.title))
116            .title_style(Theme::title())
117            .borders(Borders::ALL)
118            .border_style(Theme::dim())
119            .style(Theme::surface());
120
121        let paragraph =
122            Paragraph::new(lines).block(block).alignment(ratatui::layout::Alignment::Center);
123        frame.render_widget(paragraph, area);
124    }
125}
126
127fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
128    let vertical = Layout::vertical([Constraint::Percentage(percent_y)])
129        .flex(Flex::Center)
130        .split(area);
131    Layout::horizontal([Constraint::Percentage(percent_x)])
132        .flex(Flex::Center)
133        .split(vertical[0])[0]
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
140
141    fn key(code: KeyCode) -> KeyEvent {
142        KeyEvent {
143            code,
144            modifiers: KeyModifiers::NONE,
145            kind: KeyEventKind::Press,
146            state: KeyEventState::NONE,
147        }
148    }
149
150    #[test]
151    fn new_creates_hidden_dialog() {
152        let d = ConfirmDialog::new();
153        assert!(!d.visible);
154        assert!(d.title.is_empty());
155        assert!(d.message.is_empty());
156        assert!(!d.selected_yes);
157    }
158
159    #[test]
160    fn show_makes_dialog_visible() {
161        let mut d = ConfirmDialog::new();
162        d.show("Delete?", "Are you sure you want to delete this?");
163        assert!(d.visible);
164        assert_eq!(d.title, "Delete?");
165        assert_eq!(d.message, "Are you sure you want to delete this?");
166        // show() defaults to No selected
167        assert!(!d.selected_yes);
168    }
169
170    #[test]
171    fn hide_makes_dialog_invisible() {
172        let mut d = ConfirmDialog::new();
173        d.show("Title", "Message");
174        d.hide();
175        assert!(!d.visible);
176    }
177
178    #[test]
179    fn hidden_dialog_does_not_consume_keys() {
180        let mut d = ConfirmDialog::new();
181        let result = d.handle_key(key(KeyCode::Char('y')));
182        assert_eq!(result, None);
183    }
184
185    #[test]
186    fn y_key_confirms_and_hides() {
187        let mut d = ConfirmDialog::new();
188        d.show("Title", "Msg");
189
190        let result = d.handle_key(key(KeyCode::Char('y')));
191        assert_eq!(result, Some(true));
192        assert!(!d.visible);
193    }
194
195    #[test]
196    fn n_key_rejects_and_hides() {
197        let mut d = ConfirmDialog::new();
198        d.show("Title", "Msg");
199
200        let result = d.handle_key(key(KeyCode::Char('n')));
201        assert_eq!(result, Some(false));
202        assert!(!d.visible);
203    }
204
205    #[test]
206    fn esc_key_rejects_and_hides() {
207        let mut d = ConfirmDialog::new();
208        d.show("Title", "Msg");
209
210        let result = d.handle_key(key(KeyCode::Esc));
211        assert_eq!(result, Some(false));
212        assert!(!d.visible);
213    }
214
215    #[test]
216    fn left_right_toggle_selection() {
217        let mut d = ConfirmDialog::new();
218        d.show("Title", "Msg");
219        // Initially selected_yes is false (No selected)
220        assert!(!d.selected_yes);
221
222        // Left selects Yes
223        let result = d.handle_key(key(KeyCode::Left));
224        assert_eq!(result, None); // Still undecided
225        assert!(d.selected_yes);
226
227        // Right selects No
228        let result = d.handle_key(key(KeyCode::Right));
229        assert_eq!(result, None);
230        assert!(!d.selected_yes);
231    }
232
233    #[test]
234    fn h_l_toggle_selection() {
235        let mut d = ConfirmDialog::new();
236        d.show("Title", "Msg");
237
238        // 'h' selects Yes
239        let result = d.handle_key(key(KeyCode::Char('h')));
240        assert_eq!(result, None);
241        assert!(d.selected_yes);
242
243        // 'l' selects No
244        let result = d.handle_key(key(KeyCode::Char('l')));
245        assert_eq!(result, None);
246        assert!(!d.selected_yes);
247    }
248
249    #[test]
250    fn enter_confirms_current_selection_yes() {
251        let mut d = ConfirmDialog::new();
252        d.show("Title", "Msg");
253
254        // Select yes first
255        d.handle_key(key(KeyCode::Left));
256        assert!(d.selected_yes);
257
258        let result = d.handle_key(key(KeyCode::Enter));
259        assert_eq!(result, Some(true));
260        assert!(!d.visible);
261    }
262
263    #[test]
264    fn enter_confirms_current_selection_no() {
265        let mut d = ConfirmDialog::new();
266        d.show("Title", "Msg");
267        // Default is No
268        assert!(!d.selected_yes);
269
270        let result = d.handle_key(key(KeyCode::Enter));
271        assert_eq!(result, Some(false));
272        assert!(!d.visible);
273    }
274
275    #[test]
276    fn unrecognized_key_returns_none() {
277        let mut d = ConfirmDialog::new();
278        d.show("Title", "Msg");
279
280        let result = d.handle_key(key(KeyCode::Char('x')));
281        assert_eq!(result, None);
282        // Dialog remains visible
283        assert!(d.visible);
284    }
285
286    #[test]
287    fn show_resets_selection_to_no() {
288        let mut d = ConfirmDialog::new();
289        d.show("First", "First message");
290        // Select yes
291        d.handle_key(key(KeyCode::Left));
292        assert!(d.selected_yes);
293
294        // Show again — selection should reset to No
295        d.show("Second", "Second message");
296        assert!(!d.selected_yes);
297        assert_eq!(d.title, "Second");
298    }
299}