Files
vhaudiquet d3a07bc80d
CI / build (push) Successful in 15m3s
CI / snap (push) Successful in 2m6s
chlog: add series selector
2026-08-11 14:42:23 +02:00

222 lines
7.7 KiB
Rust

use crossterm::{
cursor, event, execute,
style::{self, Color, Print, SetForegroundColor},
terminal,
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::{self, Write};
use std::time::Duration;
pub fn create_progress_bar(
multi: &MultiProgress,
) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(50));
pb.set_style(
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}")
.unwrap(),
);
let pb_clone = pb.clone();
let callback = move |prefix: &str, msg: &str, progress: usize, total: usize| {
let pb = &pb_clone;
if progress != 0 && total != 0 {
pb.set_style(ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.unwrap()
.progress_chars("=> "));
} else {
pb.set_style(
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}")
.unwrap(),
);
}
if !prefix.is_empty() {
pb.set_prefix(prefix.to_string());
}
pb.set_message(msg.to_string());
pb.set_length(total as u64);
pb.set_position(progress as u64);
};
(pb, callback)
}
const SELECT_PROMPT: &str = "> Select series: ";
/// Interactive one-line series selector with arrow key navigation and direct typing.
///
/// The user can:
/// - Navigate with ↑/↓ arrow keys to cycle through options
/// - Type directly to enter a custom release name
/// - Press Tab to cycle through options matching the current input prefix
/// - Press Enter to confirm, Escape to use the default
///
/// Returns the selected series name, or an error if cancelled (Ctrl+C).
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(())
};
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)
}