ui: generalize interactive prompts into ui::prompt

This commit is contained in:
2026-09-16 11:15:31 +02:00
parent ae420989f9
commit 60976d3feb
2 changed files with 768 additions and 160 deletions
+21 -160
View File
@@ -5,14 +5,11 @@
pub mod deb;
/// Line classifiers rewriting raw subprocess output for the live views
pub mod logfmt;
/// Interactive raw-mode prompts: free-text input, option selection and
/// yes/no confirmation
pub mod prompt;
use crossterm::{
cursor, event, execute,
style::{self, Color, Print, SetForegroundColor},
terminal,
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::{self, Write};
use std::path::Path;
use std::time::Duration;
@@ -75,7 +72,9 @@ pub fn create_progress_bar(
(pb, callback)
}
const SELECT_PROMPT: &str = "> Select series: ";
/// Label of the interactive series selector, rendered by [`prompt::select`]
/// as `> <label><choice>`
const SELECT_SERIES_LABEL: &str = "Select series: ";
/// Interactive one-line series selector with arrow key navigation and direct typing.
///
@@ -86,165 +85,27 @@ const SELECT_PROMPT: &str = "> Select series: ";
/// - Press Enter to confirm, Escape to use the default
///
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
/// Without a terminal (piped input) or with no options, falls back to the
/// default.
pub fn select_series(
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Nothing to choose, or no terminal to ask: announce the default and take it
let fallback = || {
println!("> {SELECT_SERIES_LABEL}{default} (default)");
Ok(default.to_string())
};
if options.is_empty() {
println!("{}{} (default)", SELECT_PROMPT, default);
return Ok(default.to_string());
return fallback();
}
// Try to enter raw mode — if not possible (e.g. piped input), fall back to default
if terminal::enable_raw_mode().is_err() {
println!("{}{} (default)", SELECT_PROMPT, default);
return Ok(default.to_string());
}
let result = select_series_inner(options, default);
// Always restore the terminal
let _ = terminal::disable_raw_mode();
result
}
fn select_series_inner(
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
let mut selected_idx = default_idx;
let mut input = default.to_string();
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
// vs "typing mode" (last action was typing a character)
let mut navigating = true;
let prompt_len = SELECT_PROMPT.len();
let mut stdout = io::stdout();
// Draw the prompt line, clearing any previous content
let draw = |stdout: &mut io::Stdout, input: &str| -> Result<(), Box<dyn std::error::Error>> {
execute!(
stdout,
cursor::MoveToColumn(0),
Print(SELECT_PROMPT),
SetForegroundColor(Color::Cyan),
Print(input),
style::ResetColor,
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
Ok(())
};
draw(&mut stdout, &input)?;
loop {
if event::poll(Duration::from_millis(200))?
&& let event::Event::Key(event::KeyEvent {
code, modifiers, ..
}) = event::read()?
{
match code {
event::KeyCode::Up => {
navigating = true;
if selected_idx > 0 {
selected_idx -= 1;
} else {
selected_idx = options.len() - 1;
}
input = options[selected_idx].clone();
draw(&mut stdout, &input)?;
}
event::KeyCode::Down => {
navigating = true;
if selected_idx < options.len() - 1 {
selected_idx += 1;
} else {
selected_idx = 0;
}
input = options[selected_idx].clone();
draw(&mut stdout, &input)?;
}
event::KeyCode::Enter => {
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
// Move to a new line before returning so the terminal isn't messed up
execute!(stdout, Print("\r\n"))?;
return Err("Cancelled".into());
}
navigating = false;
input.push(c);
// Auto-select if the input exactly matches an option
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
draw(&mut stdout, &input)?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
navigating = false;
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
// Need to redraw — move cursor to end of prompt + input
execute!(
stdout,
cursor::MoveToColumn(prompt_len as u16),
Print(&input),
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
match prompt::select(SELECT_SERIES_LABEL, options, default) {
Ok(series) => Ok(series),
// prompt::select refuses to answer without a TTY; keep the
// historical fallback to the default
Err(err) if err.is::<prompt::PromptError>() => fallback(),
Err(err) => Err(err),
}
}
event::KeyCode::Tab => {
// Cycle through options that start with the current input
let matches: Vec<usize> = options
.iter()
.enumerate()
.filter(|(_, o)| o.starts_with(&input))
.map(|(i, _)| i)
.collect();
if !matches.is_empty() {
let next = if navigating {
matches
.iter()
.find(|&&i| i > selected_idx)
.or_else(|| matches.first())
} else {
matches.first()
};
if let Some(&idx) = next {
selected_idx = idx;
input = options[idx].clone();
navigating = true;
}
draw(&mut stdout, &input)?;
}
}
_ => {}
}
}
}
// Move to a new line so subsequent output doesn't overwrite the prompt
execute!(
stdout,
terminal::Clear(terminal::ClearType::UntilNewLine),
Print("\r\n"),
)?;
stdout.flush()?;
Ok(input)
}
+747
View File
@@ -0,0 +1,747 @@
//! Interactive terminal prompts sharing one raw-mode event loop: free-text
//! input with an optional validator, one-line option selection and yes/no
//! confirmation.
//!
//! Every public prompt enables raw mode itself and always restores it. When
//! no interactive terminal is attached, [`select`] fails with
//! [`PromptError::NotATty`] so callers decide on a fallback, while [`text`]
//! and [`confirm`] answer with their default.
use crossterm::{
cursor, event, execute,
style::{self, Color, Print, SetForegroundColor},
terminal,
};
use std::io::{self, Write};
use std::time::Duration;
/// Why a prompt could not run interactively
#[derive(Debug)]
pub enum PromptError {
/// Raw mode could not be enabled: stdin is not an interactive terminal
NotATty,
}
impl std::fmt::Display for PromptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromptError::NotATty => write!(f, "no interactive terminal available"),
}
}
}
impl std::error::Error for PromptError {}
/// Answer validator of [`text`]: accepts the answer, or explains why it is
/// rejected
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// What a prompt's event loop asks its drawing helper to render; keeping all
/// terminal access behind this callback makes the loops pure logic that unit
/// tests can drive with synthetic key events.
enum Render<'a> {
/// Redraw the prompt line showing this input
Line(&'a str),
/// Print a rejection message on its own line below the prompt
Rejected(&'a str),
/// Keep the line, clear any trailing characters and move to the next
/// line: the prompt was answered
Done,
/// Keep the line and move to the next line: the prompt was cancelled
/// with Ctrl+C
Cancelled,
}
/// Interactive one-line selector: ↑/↓ cycle through `options`, typing edits
/// the answer directly, Tab cycles through the options matching the current
/// input prefix, Enter confirms and Esc restores `default`. Ctrl+C cancels.
///
/// The line is rendered as `> <label><choice>`. With no options there is
/// nothing to choose and `default` is returned unchanged; without an
/// interactive terminal the call fails with [`PromptError::NotATty`] so the
/// caller decides on a fallback (see [`super::select_series`]).
pub fn select(
label: &str,
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
if options.is_empty() {
return Ok(default.to_string());
}
with_raw_mode(|| {
select_inner(
options,
default,
terminal_events,
renderer(format!("> {label}")),
)
})
}
/// Single-line free-text input: printable characters append, Backspace
/// deletes, Enter confirms, Esc restores `default` and Ctrl+C cancels.
///
/// The line is rendered as `> <label> [<default>]: `. When `validator`
/// rejects an answer, its error is printed below the prompt and the question
/// is asked again; Esc and the non-TTY fallback skip validation.
pub fn text(
label: &str,
default: &str,
validator: Option<&Validator>,
) -> Result<String, Box<dyn std::error::Error>> {
let prefix = text_prefix(label, default);
match with_raw_mode(|| {
text_inner(
default,
validator,
terminal_events,
renderer(prefix.clone()),
)
}) {
// Not a TTY: the answer is the default, unvalidated
Err(err) if err.is::<PromptError>() => {
println!("{prefix}{default}");
Ok(default.to_string())
}
other => other,
}
}
/// Yes/no confirmation: answers y/yes/n/no (case-insensitive), Enter with an
/// empty answer or Esc takes `default`, Ctrl+C cancels.
///
/// The line is rendered as `> <label> [Y/n] `, the capital letter marking the
/// default answer. Without an interactive terminal the default is taken.
pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
let prefix = confirm_prefix(label, default);
match with_raw_mode(|| confirm_inner(default, terminal_events, renderer(prefix.clone()))) {
// Not a TTY: the answer is the default
Err(err) if err.is::<PromptError>() => {
println!("{prefix}{}", if default { 'y' } else { 'n' });
Ok(default)
}
other => other,
}
}
/// Run `prompt` with the terminal in raw mode, always restoring it
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
/// enabled.
fn with_raw_mode<T>(
prompt: impl FnOnce() -> Result<T, Box<dyn std::error::Error>>,
) -> Result<T, Box<dyn std::error::Error>> {
// Try to enter raw mode — if not possible (e.g. piped input), the caller
// falls back to a non-interactive answer
if terminal::enable_raw_mode().is_err() {
return Err(Box::new(PromptError::NotATty));
}
let result = prompt();
// Always restore the terminal
let _ = terminal::disable_raw_mode();
result
}
/// Real key source: wait up to 200 ms for the next terminal event, `None`
/// when nothing arrived (the event loop keeps polling)
fn terminal_events() -> io::Result<Option<event::Event>> {
if event::poll(Duration::from_millis(200))? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
/// Drawing helper for a prompt whose line is `prefix` followed by the current
/// input, rendered in cyan
fn renderer(prefix: String) -> impl FnMut(Render<'_>) -> io::Result<()> {
let mut stdout = io::stdout();
move |action| {
match action {
Render::Line(input) => {
execute!(
stdout,
cursor::MoveToColumn(0),
Print(&prefix),
SetForegroundColor(Color::Cyan),
Print(input),
style::ResetColor,
terminal::Clear(terminal::ClearType::UntilNewLine),
)?;
stdout.flush()?;
}
Render::Rejected(message) => {
execute!(
stdout,
Print("\r\n"),
SetForegroundColor(Color::Red),
Print(message),
style::ResetColor,
Print("\r\n"),
)?;
stdout.flush()?;
}
Render::Done => {
execute!(
stdout,
terminal::Clear(terminal::ClearType::UntilNewLine),
Print("\r\n"),
)?;
stdout.flush()?;
}
Render::Cancelled => {
execute!(stdout, Print("\r\n"))?;
stdout.flush()?;
}
}
Ok(())
}
}
/// Prompt line of [`text`]: `> label [default]: `, without the brackets when
/// there is no default to show
fn text_prefix(label: &str, default: &str) -> String {
if default.is_empty() {
format!("> {label}: ")
} else {
format!("> {label} [{default}]: ")
}
}
/// Prompt line of [`confirm`]: `> label [Y/n] `, the capital letter marking
/// the default answer
fn confirm_prefix(label: &str, default: bool) -> String {
let hint = if default { "Y/n" } else { "y/N" };
format!("> {label} [{hint}] ")
}
/// Event loop of [`select`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn select_inner(
options: &[String],
default: &str,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<String, Box<dyn std::error::Error>> {
let default_idx = options.iter().position(|o| o == default).unwrap_or(0);
let mut selected_idx = default_idx;
let mut input = default.to_string();
// Whether we are in "navigation mode" (last action was arrow/tab selecting an option)
// vs "typing mode" (last action was typing a character)
let mut navigating = true;
render(Render::Line(&input))?;
loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Up => {
navigating = true;
if selected_idx > 0 {
selected_idx -= 1;
} else {
selected_idx = options.len() - 1;
}
input = options[selected_idx].clone();
render(Render::Line(&input))?;
}
event::KeyCode::Down => {
navigating = true;
if selected_idx < options.len() - 1 {
selected_idx += 1;
} else {
selected_idx = 0;
}
input = options[selected_idx].clone();
render(Render::Line(&input))?;
}
event::KeyCode::Enter => {
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
// Move to a new line before returning so the terminal isn't messed up
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
navigating = false;
input.push(c);
// Auto-select if the input exactly matches an option
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
navigating = false;
if let Some(idx) = options.iter().position(|o| o == &input) {
selected_idx = idx;
navigating = true;
}
render(Render::Line(&input))?;
}
}
event::KeyCode::Tab => {
// Cycle through options that start with the current input
let matches: Vec<usize> = options
.iter()
.enumerate()
.filter(|(_, o)| o.starts_with(&input))
.map(|(i, _)| i)
.collect();
if !matches.is_empty() {
let next = if navigating {
matches
.iter()
.find(|&&i| i > selected_idx)
.or_else(|| matches.first())
} else {
matches.first()
};
if let Some(&idx) = next {
selected_idx = idx;
input = options[idx].clone();
navigating = true;
}
render(Render::Line(&input))?;
}
}
_ => {}
}
}
// Move to a new line so subsequent output doesn't overwrite the prompt
render(Render::Done)?;
Ok(input)
}
/// Event loop of [`text`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn text_inner(
default: &str,
validator: Option<&Validator>,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<String, Box<dyn std::error::Error>> {
let mut input = String::new();
render(Render::Line(&input))?;
loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Enter => {
if let Some(validator) = validator
&& let Err(message) = validator(&input)
{
render(Render::Rejected(&message))?;
render(Render::Line(&input))?;
continue;
}
break;
}
event::KeyCode::Esc => {
input = default.to_string();
break;
}
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
input.push(c);
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
render(Render::Line(&input))?;
}
}
_ => {}
}
}
render(Render::Done)?;
Ok(input)
}
/// Event loop of [`confirm`]: pure logic over `next_event`, rendering through
/// `render` (see [`Render`])
fn confirm_inner(
default: bool,
mut next_event: impl FnMut() -> io::Result<Option<event::Event>>,
mut render: impl FnMut(Render<'_>) -> io::Result<()>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut input = String::new();
render(Render::Line(&input))?;
let answer = loop {
let Some(event::Event::Key(event::KeyEvent {
code, modifiers, ..
})) = next_event()?
else {
continue;
};
match code {
event::KeyCode::Enter => match input.to_lowercase().as_str() {
"" => break default,
"y" | "yes" => break true,
"n" | "no" => break false,
_ => {
render(Render::Rejected("Please answer y/yes or n/no"))?;
input.clear();
render(Render::Line(&input))?;
}
},
event::KeyCode::Esc => break default,
event::KeyCode::Char(c) => {
if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' {
render(Render::Cancelled)?;
return Err("Cancelled".into());
}
input.push(c);
render(Render::Line(&input))?;
}
event::KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
render(Render::Line(&input))?;
}
}
_ => {}
}
};
render(Render::Done)?;
Ok(answer)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Key press with no modifiers
fn key(code: KeyCode) -> event::Event {
event::Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Ctrl+C key press
fn ctrl_c() -> event::Event {
event::Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
}
/// Event source replaying `events`, erroring once exhausted so a missing
/// Enter/Esc/Ctrl+C fails the test instead of hanging it
fn replay(events: Vec<event::Event>) -> impl FnMut() -> io::Result<Option<event::Event>> {
let mut events = events.into_iter();
move || match events.next() {
Some(event) => Ok(Some(event)),
None => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"no more events",
)),
}
}
/// Drawing helper ignoring all rendering
fn nop_render(_: Render<'_>) -> io::Result<()> {
Ok(())
}
#[test]
fn select_down_cycles_with_wraparound() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// a → b → c → wraps back to a
let mut events = vec![key(KeyCode::Down); 3];
events.push(key(KeyCode::Enter));
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "a");
}
#[test]
fn select_up_wraps_to_last_option() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![key(KeyCode::Up), key(KeyCode::Enter)];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "c");
}
#[test]
fn select_exact_match_autoselects() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// Clear the default ("a"), type "b": it exactly matches an option and
// auto-selects it, so the following Down starts from "b"
let events = vec![
key(KeyCode::Backspace),
key(KeyCode::Char('b')),
key(KeyCode::Down),
key(KeyCode::Enter),
];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "c");
}
#[test]
fn select_tab_walks_prefix_chain() {
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
// Tab cycles through the options matching the current input:
// "n" → "no" → "noble"
let events = vec![key(KeyCode::Tab), key(KeyCode::Tab), key(KeyCode::Enter)];
let selected = select_inner(&options, "n", replay(events), nop_render).unwrap();
assert_eq!(selected, "noble");
}
#[test]
fn select_tab_wraps_around_matches() {
// "a" (the default) is the last option matching the prefix "a", so
// Tab wraps back to the first one
let options = vec!["ab".to_string(), "a".to_string()];
let events = vec![key(KeyCode::Tab), key(KeyCode::Enter)];
let selected = select_inner(&options, "a", replay(events), nop_render).unwrap();
assert_eq!(selected, "ab");
}
#[test]
fn select_tab_picks_first_match_after_typing() {
let options = vec!["n".to_string(), "no".to_string(), "noble".to_string()];
// Clear the default ("noble"), type "n" (auto-selects "n"): the next
// Tab takes the following match of the prefix
let mut events = vec![key(KeyCode::Backspace); 5];
events.extend([
key(KeyCode::Char('n')),
key(KeyCode::Tab),
key(KeyCode::Enter),
]);
let selected = select_inner(&options, "noble", replay(events), nop_render).unwrap();
assert_eq!(selected, "no");
}
#[test]
fn select_esc_returns_default() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![
key(KeyCode::Char('x')),
key(KeyCode::Char('y')),
key(KeyCode::Esc),
];
let selected = select_inner(&options, "b", replay(events), nop_render).unwrap();
assert_eq!(selected, "b");
}
#[test]
fn select_ignores_unknown_keys() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![
key(KeyCode::Left),
key(KeyCode::Right),
key(KeyCode::Home),
key(KeyCode::End),
key(KeyCode::Delete),
key(KeyCode::PageUp),
key(KeyCode::F(1)),
event::Event::Resize(80, 24),
key(KeyCode::Enter),
];
let selected = select_inner(&options, "jammy", replay(events), nop_render).unwrap();
assert_eq!(selected, "jammy");
}
#[test]
fn select_ctrl_c_cancels() {
let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
let err = select_inner(&options, "a", replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
#[test]
fn text_appends_characters() {
let events = vec![
key(KeyCode::Char('h')),
key(KeyCode::Char('i')),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "hi");
}
#[test]
fn text_backspace_deletes() {
// Backspace on an empty line is a no-op, then "ab" → Backspace → "a"
let events = vec![
key(KeyCode::Backspace),
key(KeyCode::Char('a')),
key(KeyCode::Char('b')),
key(KeyCode::Backspace),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "a");
}
#[test]
fn text_esc_returns_default() {
let events = vec![key(KeyCode::Char('x')), key(KeyCode::Esc)];
let answer = text_inner("0.1.0", None, replay(events), nop_render).unwrap();
assert_eq!(answer, "0.1.0");
}
/// Validator only accepting the literal answer "ok"
fn require_ok(answer: &str) -> Result<(), String> {
if answer == "ok" {
Ok(())
} else {
Err(format!("not ok: {answer}"))
}
}
#[test]
fn text_validator_rejects_then_accepts() {
// "no" is rejected (the loop keeps going), then edited into "ok"
let events = vec![
key(KeyCode::Char('n')),
key(KeyCode::Char('o')),
key(KeyCode::Enter),
key(KeyCode::Backspace),
key(KeyCode::Backspace),
key(KeyCode::Char('o')),
key(KeyCode::Char('k')),
key(KeyCode::Enter),
];
let answer = text_inner("0.1.0", Some(&require_ok), replay(events), nop_render).unwrap();
assert_eq!(answer, "ok");
}
#[test]
fn text_ctrl_c_cancels() {
let events = vec![key(KeyCode::Char('x')), ctrl_c()];
let err = text_inner("0.1.0", None, replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
#[test]
fn confirm_y_and_yes_agree() {
let yes = vec![key(KeyCode::Char('y')), key(KeyCode::Enter)];
let yes_spelled = vec![
key(KeyCode::Char('Y')),
key(KeyCode::Char('e')),
key(KeyCode::Char('S')),
key(KeyCode::Enter),
];
assert!(confirm_inner(false, replay(yes), nop_render).unwrap());
assert!(confirm_inner(false, replay(yes_spelled), nop_render).unwrap());
}
#[test]
fn confirm_n_and_no_deny() {
let no = vec![key(KeyCode::Char('n')), key(KeyCode::Enter)];
let no_spelled = vec![
key(KeyCode::Char('N')),
key(KeyCode::Char('o')),
key(KeyCode::Enter),
];
assert!(!confirm_inner(true, replay(no), nop_render).unwrap());
assert!(!confirm_inner(true, replay(no_spelled), nop_render).unwrap());
}
#[test]
fn confirm_enter_takes_default() {
let events = vec![key(KeyCode::Enter)];
assert!(confirm_inner(true, replay(events.clone()), nop_render).unwrap());
assert!(!confirm_inner(false, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_esc_takes_default() {
// A typed answer is discarded by Esc
let events = vec![key(KeyCode::Char('n')), key(KeyCode::Esc)];
assert!(confirm_inner(true, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_invalid_answer_reprompts() {
// "x" is rejected, the loop continues and "y" is accepted
let events = vec![
key(KeyCode::Char('x')),
key(KeyCode::Enter),
key(KeyCode::Char('y')),
key(KeyCode::Enter),
];
assert!(confirm_inner(false, replay(events), nop_render).unwrap());
}
#[test]
fn confirm_ctrl_c_cancels() {
let events = vec![ctrl_c()];
let err = confirm_inner(true, replay(events), nop_render).unwrap_err();
assert_eq!(err.to_string(), "Cancelled");
}
}