ui: generalize interactive prompts into ui::prompt
This commit is contained in:
@@ -5,14 +5,11 @@
|
|||||||
pub mod deb;
|
pub mod deb;
|
||||||
/// Line classifiers rewriting raw subprocess output for the live views
|
/// Line classifiers rewriting raw subprocess output for the live views
|
||||||
pub mod logfmt;
|
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 indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -75,7 +72,9 @@ pub fn create_progress_bar(
|
|||||||
(pb, callback)
|
(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.
|
/// 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
|
/// - Press Enter to confirm, Escape to use the default
|
||||||
///
|
///
|
||||||
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
|
/// 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(
|
pub fn select_series(
|
||||||
options: &[String],
|
options: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> 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() {
|
if options.is_empty() {
|
||||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
return fallback();
|
||||||
return Ok(default.to_string());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to enter raw mode — if not possible (e.g. piped input), fall back to default
|
match prompt::select(SELECT_SERIES_LABEL, options, default) {
|
||||||
if terminal::enable_raw_mode().is_err() {
|
Ok(series) => Ok(series),
|
||||||
println!("{}{} (default)", SELECT_PROMPT, default);
|
// prompt::select refuses to answer without a TTY; keep the
|
||||||
return Ok(default.to_string());
|
// historical fallback to the default
|
||||||
|
Err(err) if err.is::<prompt::PromptError>() => fallback(),
|
||||||
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
|
|
||||||
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()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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