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> { 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> { 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> { 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 = 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) }