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>> {
if options.is_empty() {
println!("{}{} (default)", SELECT_PROMPT, default);
return Ok(default.to_string());
}
// 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(())
// 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())
};
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)?;
}
}
_ => {}
}
}
if options.is_empty() {
return fallback();
}
// 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)
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),
}
}