Skip to main content

rtcom_tui/menu/
confirm.rs

1//! Generic yes/no confirmation dialog.
2//!
3//! [`ConfirmDialog`] is a reusable two-button dialog that emits a
4//! caller-supplied [`DialogAction`] when the user confirms (`y` / `Y`
5//! / `Enter`) and closes without action on `n` / `N` / `Esc`. It is
6//! used by the root menu for the "Write profile" and "Read profile"
7//! rows (T15) and can be reused by any future flow that needs a
8//! single-shot confirmation prompt.
9
10use crossterm::event::{KeyCode, KeyEvent};
11use ratatui::{
12    buffer::Buffer,
13    layout::Rect,
14    style::{Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Paragraph, Widget},
17};
18
19use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
20
21/// Default preferred width of a confirmation dialog, in terminal cells.
22const PREFERRED_WIDTH: u16 = 50;
23/// Default preferred height of a confirmation dialog, in terminal rows.
24const PREFERRED_HEIGHT: u16 = 8;
25
26/// Reusable yes/no confirmation dialog.
27///
28/// Constructed with a title, a prompt message, and the
29/// [`DialogAction`] to emit on confirmation. Emits
30/// [`DialogOutcome::Action`] on `y` / `Y` / `Enter` and
31/// [`DialogOutcome::Close`] on `n` / `N` / `Esc`; every other key is
32/// swallowed with [`DialogOutcome::Consumed`].
33pub struct ConfirmDialog {
34    title: String,
35    prompt: String,
36    on_confirm: DialogAction,
37    preferred_width: u16,
38    preferred_height: u16,
39}
40
41impl ConfirmDialog {
42    /// Construct a new confirmation dialog.
43    ///
44    /// `title` is used both as the window title and as the widget
45    /// title; `prompt` is rendered as the body text; `on_confirm` is
46    /// the [`DialogAction`] emitted when the user confirms.
47    #[must_use]
48    pub fn new(
49        title: impl Into<String>,
50        prompt: impl Into<String>,
51        on_confirm: DialogAction,
52    ) -> Self {
53        Self {
54            title: title.into(),
55            prompt: prompt.into(),
56            on_confirm,
57            preferred_width: PREFERRED_WIDTH,
58            preferred_height: PREFERRED_HEIGHT,
59        }
60    }
61}
62
63impl Dialog for ConfirmDialog {
64    fn title(&self) -> &str {
65        self.title.as_str()
66    }
67
68    fn render(&self, area: Rect, buf: &mut Buffer) {
69        let block = Block::bordered().title(self.title.as_str());
70        let inner = block.inner(area);
71        block.render(area, buf);
72        let lines = vec![
73            Line::from(self.prompt.as_str()),
74            Line::from(""),
75            Line::from(Span::styled(
76                "  [Y]es   [N]o / Esc to cancel  ",
77                Style::default().add_modifier(Modifier::DIM),
78            )),
79        ];
80        Paragraph::new(lines).render(inner, buf);
81    }
82
83    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
84        match key.code {
85            KeyCode::Char('y' | 'Y') | KeyCode::Enter => {
86                DialogOutcome::Action(self.on_confirm.clone())
87            }
88            KeyCode::Char('n' | 'N') | KeyCode::Esc => DialogOutcome::Close,
89            _ => DialogOutcome::Consumed,
90        }
91    }
92
93    fn preferred_size(&self, outer: Rect) -> Rect {
94        centred_rect(outer, self.preferred_width, self.preferred_height)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
102
103    const fn key(code: KeyCode) -> KeyEvent {
104        KeyEvent::new(code, KeyModifiers::NONE)
105    }
106
107    fn dialog() -> ConfirmDialog {
108        ConfirmDialog::new("Title", "Are you sure?", DialogAction::WriteProfile)
109    }
110
111    #[test]
112    fn lowercase_y_confirms() {
113        let mut d = dialog();
114        let out = d.handle_key(key(KeyCode::Char('y')));
115        assert!(matches!(
116            out,
117            DialogOutcome::Action(DialogAction::WriteProfile)
118        ));
119    }
120
121    #[test]
122    fn uppercase_y_confirms() {
123        let mut d = dialog();
124        let out = d.handle_key(key(KeyCode::Char('Y')));
125        assert!(matches!(
126            out,
127            DialogOutcome::Action(DialogAction::WriteProfile)
128        ));
129    }
130
131    #[test]
132    fn enter_confirms() {
133        let mut d = dialog();
134        let out = d.handle_key(key(KeyCode::Enter));
135        assert!(matches!(
136            out,
137            DialogOutcome::Action(DialogAction::WriteProfile)
138        ));
139    }
140
141    #[test]
142    fn lowercase_n_cancels() {
143        let mut d = dialog();
144        let out = d.handle_key(key(KeyCode::Char('n')));
145        assert!(matches!(out, DialogOutcome::Close));
146    }
147
148    #[test]
149    fn uppercase_n_cancels() {
150        let mut d = dialog();
151        let out = d.handle_key(key(KeyCode::Char('N')));
152        assert!(matches!(out, DialogOutcome::Close));
153    }
154
155    #[test]
156    fn esc_cancels() {
157        let mut d = dialog();
158        let out = d.handle_key(key(KeyCode::Esc));
159        assert!(matches!(out, DialogOutcome::Close));
160    }
161
162    #[test]
163    fn other_key_consumed() {
164        let mut d = dialog();
165        let out = d.handle_key(key(KeyCode::Char('x')));
166        assert!(matches!(out, DialogOutcome::Consumed));
167    }
168
169    #[test]
170    fn title_round_trips() {
171        let d = ConfirmDialog::new("Write profile", "prompt", DialogAction::WriteProfile);
172        assert_eq!(d.title(), "Write profile");
173    }
174
175    #[test]
176    fn preferred_size_50x8_centred() {
177        let d = dialog();
178        let outer = Rect {
179            x: 0,
180            y: 0,
181            width: 80,
182            height: 24,
183        };
184        let pref = d.preferred_size(outer);
185        assert_eq!(pref.width, 50);
186        assert_eq!(pref.height, 8);
187    }
188}