//! Terminal helpers — ergonomic CLI interaction utilities. //! //! Provides: //! - Progress spinner (blocking) //! - Confirm prompt (y/n) //! - Text input with simple editing //! - Numbered selection menu //! - Status message display (success/error/warning) use std::io::{self, Write}; use crossterm::cursor; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use crossterm::execute; use crossterm::style::Stylize; use crossterm::terminal::{self, ClearType}; /// Status kind for `show_status`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusKind { /// Green checkmark — operation succeeded Success, /// Red X — operation failed Error, /// Yellow triangle — warning Warning, /// Blue info — informational message Info, } /// Run a simple spinner while a closure executes. /// /// The spinner displays rotating characters: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` /// Returns the result of the closure. pub fn progress_spinner(label: &str, f: F) -> T where F: FnOnce() -> T, { const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; // Show initial spinner print!("{} {}", SPINNER[0], label); io::stdout().flush().ok(); // Run the closure let result = f(); // Clear spinner line let _ = execute!(io::stdout(), terminal::Clear(ClearType::CurrentLine)); let _ = io::stdout().flush(); result } /// Prompt the user for a yes/no answer. /// /// Returns `true` for y/Y/yes/Yes, `false` for n/N/no/No. /// Loops until a valid answer is given. pub fn confirm_prompt(prompt: &str, default: Option) -> bool { let hint = match default { Some(true) => "[Y/n]", Some(false) => "[y/N]", None => "[y/n]", }; loop { print!("{} {}: ", prompt, hint); io::stdout().flush().ok(); let mut input = String::new(); io::stdin().read_line(&mut input).ok(); let input = input.trim().to_lowercase(); match input.as_str() { "y" | "yes" => return true, "n" | "no" => return false, "" => { if let Some(d) = default { return d; } } _ => { eprintln!(" Please answer 'y' or 'n'."); } } } } /// Collect text input from the user with basic editing support. /// /// Uses crossterm for raw mode input. Supports: /// - Typing characters /// - Backspace to delete /// - Enter to submit /// - Ctrl+C to abort (returns None) pub fn text_input(prompt: &str, default: Option<&str>) -> Option { let _raw = crossterm::terminal::enable_raw_mode().ok()?; let mut buffer = default.map(|s| s.to_string()).unwrap_or_default(); let prompt_line = format!("{}: ", prompt); // Print prompt and buffer print!("{}{}", prompt_line, buffer); io::stdout().flush().ok(); let result = loop { match event::read() { Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code { KeyCode::Char(c) => { buffer.push(c); print!("{}", c); io::stdout().flush().ok(); } KeyCode::Backspace => { if buffer.pop().is_some() { print!("\x08 \x08"); io::stdout().flush().ok(); } } KeyCode::Enter => { println!(); break Some(buffer); } KeyCode::Esc => { println!(); break None; } _ => {} }, Ok(Event::Key(key)) if key.code == KeyCode::Char('c') && key.modifiers == crossterm::event::KeyModifiers::CONTROL => { println!("^C"); break None; } Err(_) => { break None; } _ => {} } }; crossterm::terminal::disable_raw_mode().ok(); result } /// Display a numbered selection menu and return the index of the chosen item. /// /// Returns `None` if cancelled (Ctrl+C / Esc). pub fn selection_menu(prompt: &str, items: &[&str]) -> Option { if items.is_empty() { return None; } println!(); println!("{}", prompt); for (i, item) in items.iter().enumerate() { println!(" {}. {}", i + 1, item); } println!(" 0. Cancel"); loop { print!("Enter choice [0-{}]: ", items.len()); io::stdout().flush().ok(); let mut input = String::new(); io::stdin().read_line(&mut input).ok(); let input = input.trim(); if input.is_empty() { continue; } match input.parse::() { Ok(0) => return None, Ok(n) if n >= 1 && n <= items.len() => return Some(n - 1), _ => { eprintln!( " Invalid choice. Enter a number between 0 and {}.", items.len() ); } } } } /// Display a status message with a colored icon. /// /// - Success: ✅ (or [OK]) /// - Error: ❌ (or [ERR]) /// - Warning: ⚠️ (or [WARN]) /// - Info: ℹ️ (or [INFO]) pub fn show_status(kind: StatusKind, message: &str) { let (icon, styled_msg) = match kind { StatusKind::Success => ("✅ ", message.green()), StatusKind::Error => ("❌ ", message.red()), StatusKind::Warning => ("⚠️ ", message.yellow()), StatusKind::Info => ("ℹ️ ", message.blue()), }; // Try to write styled; fall back to plain on error let full = format!("{}{}", icon, styled_msg); println!("{}", full); } /// Clear the terminal screen. pub fn clear_screen() { let _ = execute!(io::stdout(), terminal::Clear(ClearType::All)); let _ = execute!(io::stdout(), cursor::MoveTo(0, 0)); } /// Print a section header with a divider line. pub fn section_header(title: &str) { let line = "─".repeat(title.len().saturating_add(4)); println!(); println!(" {} ", line.as_str().bold()); println!(" {} ", title.bold()); println!(" {} ", line.as_str().bold()); println!(); } #[cfg(test)] mod tests { use super::*; #[test] fn test_status_kind_equality() { assert_eq!(StatusKind::Success, StatusKind::Success); assert_ne!(StatusKind::Success, StatusKind::Error); } #[test] fn test_selection_menu_empty() { assert!(selection_menu("test", &[]).is_none()); } #[test] fn test_confirm_prompt_parsing() { // Can't really test interactively, but we can check the fn compiles assert!(std::mem::size_of::) -> bool>() > 0); } #[test] fn test_clear_screen_does_not_panic() { let _ = execute!(io::stdout(), terminal::Clear(ClearType::All)); } #[test] fn test_section_header_does_not_panic() { section_header("Test Header"); } }