Skip to main content

urushi_prompt/
confirm.rs

1use std::any::Any;
2
3use crate::{
4    FieldConfigError, FieldKey,
5    runtime::{
6        self, Event, FieldAction, FieldEntry, FieldPresentation, FieldRegionKind, KeyCode,
7        KeyModifiers, PromptStyles, RuntimeField, TextSpan, clipped_line_view, field_line_view,
8        fixed_view, line_view, region,
9    },
10};
11use urushi::{Align, BlockStyle, GridStyle, Length, VerticalAlign, View};
12
13/// The provenance of a submitted confirmation value.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfirmSource {
16    /// The user submitted the configured default without selecting an answer.
17    Default,
18    /// The user selected an answer with a key before submitting.
19    Explicit,
20}
21
22/// A submitted confirmation value and how it was chosen.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ConfirmAnswer {
25    /// The selected yes (`true`) or no (`false`) answer.
26    pub value: bool,
27    /// Whether the value came from the default or an explicit keypress.
28    pub source: ConfirmSource,
29}
30
31/// A yes-or-no field with an optional default answer.
32pub struct Confirm {
33    key: FieldKey<ConfirmAnswer>,
34    question: String,
35    description: Option<String>,
36    default: Option<bool>,
37    selected: Option<bool>,
38    source: Option<ConfirmSource>,
39    yes_label: String,
40    no_label: String,
41    button_alignment: Align,
42    help: String,
43    unanswered_message: String,
44    show_unanswered: bool,
45}
46
47impl Confirm {
48    /// Creates a confirmation field with an optional default answer.
49    pub fn new(
50        key: FieldKey<ConfirmAnswer>,
51        question: impl Into<String>,
52        default: Option<bool>,
53    ) -> Result<Self, FieldConfigError> {
54        if key.name().is_empty() {
55            return Err(FieldConfigError::EmptyName);
56        }
57
58        Ok(Self {
59            key,
60            question: question.into(),
61            description: None,
62            default,
63            selected: default,
64            source: None,
65            yes_label: "Yes".to_owned(),
66            no_label: "No".to_owned(),
67            button_alignment: Align::Left,
68            help: "←/→ choose • y yes • n no • enter submit • shift+tab back • esc cancel"
69                .to_owned(),
70            unanswered_message: "Choose yes or no.".to_owned(),
71            show_unanswered: false,
72        })
73    }
74
75    /// Sets the labels shown for yes and no.
76    /// `yes` and `no` is plain text. Escape sequences in it are counted as ordinary
77    /// characters when the prompt measures its cells, so a pre-styled string
78    /// mis-aligns the field; style it through the prompt's theme instead.
79    #[must_use]
80    pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
81        self.yes_label = yes.into();
82        self.no_label = no.into();
83        self
84    }
85
86    /// Sets the horizontal alignment of the yes and no buttons.
87    ///
88    /// The default is [`Align::Left`]. Center and right alignment position the
89    /// buttons within the natural width of the question and description.
90    #[must_use]
91    pub fn button_alignment(mut self, alignment: Align) -> Self {
92        self.button_alignment = alignment;
93        self
94    }
95
96    /// Sets supporting text shown below the question.
97    /// `description` is plain text. Escape sequences in it are counted as ordinary
98    /// characters when the prompt measures its cells, so a pre-styled string
99    /// mis-aligns the field; style it through the prompt's theme instead.
100    #[must_use]
101    pub fn description(mut self, description: impl Into<String>) -> Self {
102        self.description = Some(description.into());
103        self
104    }
105
106    /// Sets the navigation hint shown beneath the choices.
107    /// `help` is plain text. Escape sequences in it are counted as ordinary
108    /// characters when the prompt measures its cells, so a pre-styled string
109    /// mis-aligns the field; style it through the prompt's theme instead.
110    #[must_use]
111    pub fn help(mut self, help: impl Into<String>) -> Self {
112        self.help = help.into();
113        self
114    }
115
116    /// Sets the message shown when a confirmation without a default is unanswered.
117    #[must_use]
118    pub fn unanswered_message(mut self, message: impl Into<String>) -> Self {
119        self.unanswered_message = message.into();
120        self
121    }
122
123    fn select_explicit(&mut self, value: bool) {
124        self.selected = Some(value);
125        self.source = Some(ConfirmSource::Explicit);
126        self.show_unanswered = false;
127    }
128
129    fn submit(&mut self) -> FieldAction {
130        match (self.source, self.default) {
131            (Some(_), _) => FieldAction::Accept,
132            (None, Some(default)) => {
133                self.selected = Some(default);
134                self.source = Some(ConfirmSource::Default);
135                FieldAction::Accept
136            }
137            (None, None) => {
138                self.show_unanswered = true;
139                FieldAction::Stay
140            }
141        }
142    }
143}
144
145impl runtime::private::Sealed for Confirm {
146    fn into_entry(self: Box<Self>) -> FieldEntry {
147        FieldEntry::new(self.key.name().to_owned(), self)
148    }
149}
150
151impl RuntimeField for Confirm {
152    fn event(&mut self, event: Event) -> FieldAction {
153        let Event::Key(key) = event else {
154            return FieldAction::Stay;
155        };
156
157        match (
158            key.code,
159            key.modifiers.contains(KeyModifiers::CONTROL),
160            key.modifiers.contains(KeyModifiers::ALT),
161        ) {
162            (KeyCode::Escape, _, _) => FieldAction::Cancel,
163            (KeyCode::Up | KeyCode::Left, _, _) | (KeyCode::Char('h'), false, false) => {
164                self.select_explicit(true);
165                FieldAction::Stay
166            }
167            (KeyCode::Down | KeyCode::Right, _, _) | (KeyCode::Char('l'), false, false) => {
168                self.select_explicit(false);
169                FieldAction::Stay
170            }
171            (KeyCode::Char('y' | 'Y'), false, false) => {
172                self.select_explicit(true);
173                FieldAction::Accept
174            }
175            (KeyCode::Char('n' | 'N'), false, false) => {
176                self.select_explicit(false);
177                FieldAction::Accept
178            }
179            (KeyCode::Enter | KeyCode::Tab, _, _) => self.submit(),
180            _ => FieldAction::Stay,
181        }
182    }
183
184    fn take_value(&mut self) -> Box<dyn Any> {
185        let source = match self.source {
186            Some(source) => source,
187            None => unreachable!("a confirm value is collected only after acceptance"),
188        };
189        Box::new(ConfirmAnswer {
190            value: match self.selected {
191                Some(value) => value,
192                None => unreachable!("a confirm value is collected only after acceptance"),
193            },
194            source,
195        })
196    }
197
198    fn view(&self, styles: &PromptStyles, focused: bool, _width: usize) -> FieldPresentation {
199        let question = region(
200            FieldRegionKind::Question,
201            field_line_view(
202                styles,
203                focused,
204                line_view(vec![TextSpan::new(
205                    self.question.clone(),
206                    styles.question(focused).clone(),
207                )]),
208            ),
209        );
210        let mut header = vec![question];
211        let mut regions = vec![
212            FieldRegionKind::Question,
213            FieldRegionKind::Control,
214            FieldRegionKind::Focus,
215        ];
216        if let Some(description) = &self.description {
217            header.push(region(
218                FieldRegionKind::Description,
219                field_line_view(
220                    styles,
221                    focused,
222                    line_view(vec![TextSpan::new(
223                        description.clone(),
224                        styles.muted.clone(),
225                    )]),
226                ),
227            ));
228            regions.push(FieldRegionKind::Description);
229        }
230        header.push(field_line_view(
231            styles,
232            focused,
233            View::text("", styles.body.clone()),
234        ));
235        let mut buttons = Vec::new();
236        let focused_value = self.selected.unwrap_or(true);
237        for (index, (value, label)) in [(true, &self.yes_label), (false, &self.no_label)]
238            .into_iter()
239            .enumerate()
240        {
241            let button_focused = self.selected == Some(value);
242            let style = if button_focused {
243                &styles.button_focused
244            } else {
245                &styles.button
246            };
247            if index > 0 {
248                buttons.push(View::text(" ", styles.body.clone()));
249            }
250            let button = View::text(format!("  {label}  "), style.clone());
251            buttons.push(if focused_value == value {
252                region(FieldRegionKind::Focus, button)
253            } else {
254                button
255            });
256        }
257        let aligned_buttons = View::block(
258            BlockStyle::new()
259                .width(Length::fill(1))
260                .align(self.button_alignment),
261            View::row(VerticalAlign::Top, buttons),
262        );
263        let buttons = region(
264            FieldRegionKind::Control,
265            field_line_view(styles, focused, aligned_buttons),
266        );
267        let alignment_group = View::grid(
268            GridStyle::new(),
269            [
270                [View::block(
271                    BlockStyle::new().align(Align::Left),
272                    View::column(Align::Left, header),
273                )],
274                [buttons],
275            ],
276        );
277        let mut body = vec![alignment_group];
278        if self.show_unanswered {
279            body.push(region(
280                FieldRegionKind::Error,
281                field_line_view(
282                    styles,
283                    focused,
284                    View::row(
285                        VerticalAlign::Top,
286                        [
287                            fixed_view(2, vec![TextSpan::new("! ", styles.error.clone())]),
288                            View::text(self.unanswered_message.clone(), styles.error.clone()),
289                        ],
290                    ),
291                ),
292            ));
293            regions.push(FieldRegionKind::Error);
294        }
295        FieldPresentation::new(View::column(Align::Left, body))
296            .with_help(clipped_line_view(vec![TextSpan::new(
297                self.help.clone(),
298                styles.help.clone(),
299            )]))
300            .with_regions(regions)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::runtime::test_styles;
308
309    fn key(code: KeyCode) -> Event {
310        Event::Key(runtime::KeyEvent::new(code))
311    }
312
313    #[test]
314    fn constructor_rejects_an_empty_name() {
315        assert!(matches!(
316            Confirm::new(FieldKey::new(""), "Continue?", None),
317            Err(FieldConfigError::EmptyName)
318        ));
319    }
320
321    #[test]
322    fn default_is_selected_visually_but_source_is_undecided_until_submit() {
323        let mut confirm = Confirm::new(FieldKey::new("confirm"), "Continue?", Some(false))
324            .expect("confirm is valid");
325        let styles = test_styles();
326        let initial = confirm.view(&styles, true, 80);
327        assert_eq!(initial.cursor, None);
328        assert!(
329            initial.rows()[0]
330                .runs()
331                .iter()
332                .any(|run| run.style == styles.question)
333        );
334        assert!(
335            initial.rows()[2]
336                .runs()
337                .iter()
338                .any(|span| span.style == styles.button_focused && span.text == "  No  ")
339        );
340        assert_eq!(confirm.source, None);
341
342        assert_eq!(confirm.event(key(KeyCode::Enter)), FieldAction::Accept);
343        assert_eq!(confirm.source, Some(ConfirmSource::Default));
344        let answer = confirm.take_value();
345        assert_eq!(
346            answer.downcast_ref::<ConfirmAnswer>(),
347            Some(&ConfirmAnswer {
348                value: false,
349                source: ConfirmSource::Default,
350            })
351        );
352    }
353
354    #[test]
355    fn explicit_choice_is_explicit_even_when_it_matches_the_default() {
356        let mut confirm = Confirm::new(FieldKey::new("confirm"), "Continue?", Some(true))
357            .expect("confirm is valid");
358        assert_eq!(confirm.event(key(KeyCode::Char('y'))), FieldAction::Accept);
359        assert_eq!(confirm.source, Some(ConfirmSource::Explicit));
360        let answer = confirm.take_value();
361        assert_eq!(
362            answer.downcast_ref::<ConfirmAnswer>(),
363            Some(&ConfirmAnswer {
364                value: true,
365                source: ConfirmSource::Explicit,
366            })
367        );
368    }
369
370    #[test]
371    fn no_default_requires_an_explicit_choice_and_shows_help() {
372        let mut confirm = Confirm::new(FieldKey::new("confirm"), "Continue?", None)
373            .expect("confirm is valid")
374            .labels("Proceed", "Stop")
375            .help("Choose, then press Enter.")
376            .unanswered_message("Choose an answer.");
377        let styles = test_styles();
378        let initial = confirm.view(&styles, true, 80);
379        assert!(
380            initial.rows()[2]
381                .runs()
382                .iter()
383                .any(|run| run.style == styles.button && run.text == "  Proceed  ")
384        );
385        assert!(
386            initial.rows()[2]
387                .runs()
388                .iter()
389                .any(|run| run.style == styles.button && run.text == "  Stop  ")
390        );
391        assert_eq!(confirm.event(key(KeyCode::Enter)), FieldAction::Stay);
392        let answered = confirm.view(&styles, true, 80);
393        assert!(
394            answered
395                .regions
396                .iter()
397                .any(|region| region.kind == FieldRegionKind::Error)
398        );
399        assert!(answered.rows()[3].text().contains("! Choose an answer."));
400        assert!(answered.help.is_some());
401        assert_eq!(confirm.event(key(KeyCode::Right)), FieldAction::Stay);
402        assert_eq!(confirm.view(&styles, true, 80).rows().len(), 3);
403        assert_eq!(confirm.event(key(KeyCode::Enter)), FieldAction::Accept);
404    }
405
406    #[test]
407    fn buttons_are_left_aligned_by_default() {
408        let confirm = Confirm::new(
409            FieldKey::new("confirm"),
410            "Generate the personalized greeting?",
411            Some(true),
412        )
413        .expect("confirm is valid");
414
415        let styles = test_styles();
416        let view = confirm.view(&styles, true, 80);
417        assert_eq!(view.rows()[1].text().trim(), "┃");
418        assert!(
419            view.rows()[2]
420                .runs()
421                .iter()
422                .any(|run| run.style == styles.button_focused && run.text == "  Yes  ")
423        );
424    }
425
426    #[test]
427    fn buttons_can_be_centered_within_the_natural_header_width() {
428        let confirm = Confirm::new(
429            FieldKey::new("confirm"),
430            "Generate the personalized greeting?",
431            Some(true),
432        )
433        .expect("confirm is valid")
434        .button_alignment(Align::Center);
435
436        let styles = test_styles();
437        let view = confirm.view(&styles, true, 80);
438        assert!(view.rows()[2].text().starts_with("┃ "));
439        assert_eq!(view.rows()[2].text().find("Yes"), Some(16));
440        assert!(
441            view.rows()[2]
442                .runs()
443                .iter()
444                .any(|run| run.style == styles.button_focused && run.text == "  Yes  ")
445        );
446    }
447
448    #[test]
449    fn buttons_can_be_right_aligned_within_the_natural_header_width() {
450        let confirm = Confirm::new(
451            FieldKey::new("confirm"),
452            "Generate the personalized greeting?",
453            Some(true),
454        )
455        .expect("confirm is valid")
456        .button_alignment(Align::Right);
457
458        let styles = test_styles();
459        let view = confirm.view(&styles, true, 80);
460        assert!(view.rows()[2].text().starts_with("┃ "));
461        assert_eq!(view.rows()[2].text().find("Yes"), Some(27));
462        assert!(
463            view.rows()[2]
464                .runs()
465                .iter()
466                .any(|run| run.style == styles.button_focused && run.text == "  Yes  ")
467        );
468    }
469
470    #[test]
471    fn grid_alignment_uses_the_widest_header_or_control_cell() {
472        let styles = test_styles();
473        let start = |confirm: Confirm, label: &str| {
474            confirm
475                .view(&styles, true, 80)
476                .rows()
477                .into_iter()
478                .find_map(|row| row.text().find(label))
479                .expect("button label is rendered")
480        };
481
482        let buttons_wider = |alignment| {
483            Confirm::new(FieldKey::new("confirm"), "Q", Some(true))
484                .expect("confirm")
485                .labels("A deliberately long answer", "No")
486                .button_alignment(alignment)
487        };
488        assert_eq!(
489            start(buttons_wider(Align::Left), "A deliberately"),
490            start(buttons_wider(Align::Center), "A deliberately")
491        );
492        assert_eq!(
493            start(buttons_wider(Align::Left), "A deliberately"),
494            start(buttons_wider(Align::Right), "A deliberately")
495        );
496
497        let description_wider = |alignment| {
498            Confirm::new(FieldKey::new("confirm"), "Q", Some(true))
499                .expect("confirm")
500                .description("A description wider than either button row")
501                .button_alignment(alignment)
502        };
503        let left = start(description_wider(Align::Left), "Yes");
504        let center = start(description_wider(Align::Center), "Yes");
505        let right = start(description_wider(Align::Right), "Yes");
506        assert!(left < center && center < right);
507    }
508}