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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConfirmSource {
16 Default,
18 Explicit,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ConfirmAnswer {
25 pub value: bool,
27 pub source: ConfirmSource,
29}
30
31pub 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 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 #[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 #[must_use]
91 pub fn button_alignment(mut self, alignment: Align) -> Self {
92 self.button_alignment = alignment;
93 self
94 }
95
96 #[must_use]
101 pub fn description(mut self, description: impl Into<String>) -> Self {
102 self.description = Some(description.into());
103 self
104 }
105
106 #[must_use]
111 pub fn help(mut self, help: impl Into<String>) -> Self {
112 self.help = help.into();
113 self
114 }
115
116 #[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}