ui: generalize interactive prompts into ui::prompt
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user