| use std::sync::Arc; |
| use std::sync::atomic::{AtomicBool, Ordering}; |
| use std::thread::{self, JoinHandle}; |
| use std::time::{Duration, Instant}; |
|
|
| use anyhow::Result; |
| use colored::Colorize; |
| use forge_domain::ConsoleWriter; |
| use rand::RngExt; |
|
|
| mod progress_bar; |
|
|
| pub use progress_bar::*; |
|
|
| const TICK_DURATION_MS: u64 = 60; |
| const TICKS: &[&str; 10] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; |
| const MIN_TERMINAL_WIDTH: usize = 12; |
| const WRAP_GUARD_COLUMNS: usize = 8; |
|
|
| fn terminal_width() -> usize { |
| terminal_size::terminal_size() |
| .map(|(width, _)| width.0 as usize) |
| .unwrap_or(80) |
| } |
|
|
| fn visible_width(value: &str) -> usize { |
| console::measure_text_width(value) |
| } |
|
|
| fn truncate_to_visible_width(value: &str, max_width: usize) -> String { |
| let mut output = value.to_string(); |
|
|
| while visible_width(&output) > max_width { |
| if output.pop().is_none() { |
| break; |
| } |
| } |
|
|
| output |
| } |
|
|
| fn styled_loader_line( |
| tick: &str, |
| message: &str, |
| elapsed: Duration, |
| terminal_width: usize, |
| ) -> String { |
| let elapsed = format_elapsed_time(elapsed); |
| let suffix = "· Ctrl+C to interrupt"; |
| let max_width = terminal_width |
| .saturating_sub(WRAP_GUARD_COLUMNS) |
| .max(MIN_TERMINAL_WIDTH); |
|
|
| let tick = tick.green().to_string(); |
| let elapsed = elapsed.white().to_string(); |
| let suffix = suffix.white().dimmed().to_string(); |
| let fixed = format!("{tick} {elapsed} {suffix}"); |
| let message_width = max_width.saturating_sub(visible_width(&fixed)).max(1); |
| let message = truncate_to_visible_width(message, message_width) |
| .green() |
| .bold() |
| .to_string(); |
| let styled = format!("{tick} {message} {elapsed} {suffix}"); |
|
|
| truncate_to_visible_width(&styled, max_width) |
| } |
|
|
| struct ActiveSpinner<P: ConsoleWriter> { |
| stop: Arc<AtomicBool>, |
| paused: Arc<AtomicBool>, |
| handle: Option<JoinHandle<()>>, |
| started_at: Instant, |
| accumulated_elapsed: Duration, |
| printer: Arc<P>, |
| } |
|
|
| impl<P: ConsoleWriter + 'static> ActiveSpinner<P> { |
| fn start(printer: Arc<P>, accumulated_elapsed: Duration, message: String) -> Self { |
| let stop = Arc::new(AtomicBool::new(false)); |
| let paused = Arc::new(AtomicBool::new(false)); |
| let stop_signal = Arc::clone(&stop); |
| let paused_signal = Arc::clone(&paused); |
| let thread_printer = Arc::clone(&printer); |
| let started_at = Instant::now(); |
|
|
| let handle = thread::spawn(move || { |
| loop { |
| if stop_signal.load(Ordering::Acquire) { |
| break; |
| } |
|
|
| if paused_signal.load(Ordering::Acquire) { |
| thread::park_timeout(Duration::from_millis(TICK_DURATION_MS)); |
| continue; |
| } |
|
|
| let elapsed = accumulated_elapsed + started_at.elapsed(); |
| let tick_index = ((elapsed.as_millis() / TICK_DURATION_MS as u128) |
| % TICKS.len() as u128) as usize; |
| let tick = TICKS.get(tick_index).unwrap_or(&"⠋"); |
| let line = styled_loader_line(tick, &message, elapsed, terminal_width()); |
|
|
| if !stop_signal.load(Ordering::Acquire) && !paused_signal.load(Ordering::Acquire) { |
| let _ = thread_printer.write_err(format!("\r\x1b[2K{line}").as_bytes()); |
| let _ = thread_printer.flush_err(); |
| } |
|
|
| thread::park_timeout(Duration::from_millis(TICK_DURATION_MS)); |
| } |
| }); |
|
|
| Self { |
| stop, |
| paused, |
| handle: Some(handle), |
| started_at, |
| accumulated_elapsed, |
| printer, |
| } |
| } |
| } |
|
|
| impl<P: ConsoleWriter> ActiveSpinner<P> { |
| fn elapsed(&self) -> Duration { |
| self.accumulated_elapsed + self.started_at.elapsed() |
| } |
|
|
| fn pause(&self) { |
| let was_paused = self.paused.swap(true, Ordering::AcqRel); |
| if !was_paused { |
| self.clear_line(); |
| } |
| if let Some(handle) = &self.handle { |
| handle.thread().unpark(); |
| } |
| } |
|
|
| fn resume(&self) { |
| self.paused.store(false, Ordering::Release); |
| if let Some(handle) = &self.handle { |
| handle.thread().unpark(); |
| } |
| } |
|
|
| fn clear_line(&self) { |
| let _ = self.printer.write_err(b"\r\x1b[2K"); |
| let _ = self.printer.flush_err(); |
| } |
|
|
| fn finish(&mut self) -> Duration { |
| self.stop.store(true, Ordering::Release); |
| if let Some(handle) = &self.handle { |
| handle.thread().unpark(); |
| } |
| if let Some(handle) = self.handle.take() { |
| let _ = handle.join(); |
| } |
| let elapsed = self.elapsed(); |
| self.clear_line(); |
| elapsed |
| } |
| } |
|
|
| impl<P: ConsoleWriter> Drop for ActiveSpinner<P> { |
| fn drop(&mut self) { |
| self.stop.store(true, Ordering::Release); |
| if let Some(handle) = &self.handle { |
| handle.thread().unpark(); |
| } |
| if let Some(handle) = self.handle.take() { |
| let _ = handle.join(); |
| } |
| self.clear_line(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| fn format_elapsed_time(duration: Duration) -> String { |
| let total_seconds = duration.as_secs(); |
| if total_seconds < 60 { |
| format!("{:02}s", total_seconds) |
| } else if total_seconds < 3600 { |
| let minutes = total_seconds / 60; |
| let seconds = total_seconds % 60; |
| format!("{}:{:02}m", minutes, seconds) |
| } else { |
| let hours = total_seconds / 3600; |
| let minutes = (total_seconds % 3600) / 60; |
| format!("{}:{:02}h", hours, minutes) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| pub struct SpinnerManager<P: ConsoleWriter> { |
| spinner: Option<ActiveSpinner<P>>, |
| accumulated_elapsed: Duration, |
| word_index: Option<usize>, |
| message: Option<String>, |
| printer: Arc<P>, |
| } |
|
|
| impl<P: ConsoleWriter + 'static> SpinnerManager<P> { |
| |
| pub fn new(printer: Arc<P>) -> Self { |
| Self { |
| spinner: None, |
| accumulated_elapsed: Duration::ZERO, |
| word_index: None, |
| message: None, |
| printer, |
| } |
| } |
|
|
| |
| pub fn start(&mut self, message: Option<&str>) -> Result<()> { |
| self.stop(None)?; |
|
|
| let words = [ |
| "Thinking", |
| "Processing", |
| "Analyzing", |
| "Forging", |
| "Researching", |
| "Synthesizing", |
| "Reasoning", |
| "Contemplating", |
| ]; |
|
|
| |
| let word = match message { |
| Some(msg) => msg.to_string(), |
| None => { |
| let idx = *self |
| .word_index |
| .get_or_insert_with(|| rand::rng().random_range(0..words.len())); |
| words.get(idx).unwrap_or(&"Loading").to_string() |
| } |
| }; |
|
|
| self.message = Some(word.clone()); |
|
|
| let spinner = ActiveSpinner::start(self.printer.clone(), self.accumulated_elapsed, word); |
| self.spinner = Some(spinner); |
|
|
| Ok(()) |
| } |
|
|
| |
| pub fn pause(&mut self) { |
| if let Some(spinner) = &self.spinner { |
| spinner.pause(); |
| } |
| } |
|
|
| |
| pub fn resume(&mut self) { |
| if let Some(spinner) = &self.spinner { |
| spinner.resume(); |
| } |
| } |
|
|
| |
| pub fn stop(&mut self, message: Option<String>) -> Result<()> { |
| if let Some(mut spinner) = self.spinner.take() { |
| |
| self.accumulated_elapsed = spinner.finish(); |
| if let Some(msg) = message { |
| self.println(&msg); |
| } |
| } else if let Some(message) = message { |
| self.println(&message); |
| } |
|
|
| self.message = None; |
|
|
| Ok(()) |
| } |
|
|
| |
| pub fn set_message(&mut self, message: &str) -> Result<()> { |
| self.message = Some(message.to_owned()); |
| if self.spinner.is_some() { |
| self.stop(None)?; |
| self.start(Some(message))?; |
| } |
| Ok(()) |
| } |
|
|
| |
| |
| pub fn reset(&mut self) { |
| self.accumulated_elapsed = Duration::ZERO; |
| self.word_index = None; |
| self.message = None; |
| } |
|
|
| |
| pub fn write_ln(&mut self, message: impl ToString) -> Result<()> { |
| let msg = message.to_string(); |
| let was_active = self.spinner.is_some(); |
| if was_active { |
| self.pause(); |
| } |
| self.println(&msg); |
| if was_active { |
| self.resume(); |
| } |
| Ok(()) |
| } |
|
|
| |
| pub fn ewrite_ln(&mut self, message: impl ToString) -> Result<()> { |
| let msg = message.to_string(); |
| let was_active = self.spinner.is_some(); |
| if was_active { |
| self.pause(); |
| } |
| self.eprintln(&msg); |
| if was_active { |
| self.resume(); |
| } |
| Ok(()) |
| } |
|
|
| |
| fn println(&self, msg: &str) { |
| let line = format!("{msg}\n"); |
| let _ = self.printer.write(line.as_bytes()); |
| let _ = self.printer.flush(); |
| } |
|
|
| |
| fn eprintln(&self, msg: &str) { |
| let line = format!("{msg}\n"); |
| let _ = self.printer.write_err(line.as_bytes()); |
| let _ = self.printer.flush_err(); |
| } |
| } |
|
|
| impl<P: ConsoleWriter> Drop for SpinnerManager<P> { |
| fn drop(&mut self) { |
| |
| if let Some(mut spinner) = self.spinner.take() { |
| self.accumulated_elapsed = spinner.finish(); |
| } |
| |
| |
| let _ = self.printer.flush(); |
| let _ = self.printer.flush_err(); |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::io::Write; |
| use std::sync::Arc; |
| use std::sync::atomic::Ordering; |
| use std::time::Duration; |
|
|
| use forge_domain::ConsoleWriter; |
| use pretty_assertions::assert_eq; |
|
|
| use super::{SpinnerManager, format_elapsed_time}; |
|
|
| |
| |
| #[derive(Clone, Copy)] |
| struct DirectPrinter; |
|
|
| impl ConsoleWriter for DirectPrinter { |
| fn write(&self, buf: &[u8]) -> std::io::Result<usize> { |
| std::io::stdout().write(buf) |
| } |
|
|
| fn write_err(&self, buf: &[u8]) -> std::io::Result<usize> { |
| std::io::stderr().write(buf) |
| } |
|
|
| fn flush(&self) -> std::io::Result<()> { |
| std::io::stdout().flush() |
| } |
|
|
| fn flush_err(&self) -> std::io::Result<()> { |
| std::io::stderr().flush() |
| } |
| } |
|
|
| fn fixture_spinner() -> SpinnerManager<DirectPrinter> { |
| SpinnerManager::new(Arc::new(DirectPrinter)) |
| } |
|
|
| #[test] |
| fn test_spinner_reset_clears_accumulated_time() { |
| let mut fixture_spinner = fixture_spinner(); |
|
|
| |
| fixture_spinner.accumulated_elapsed = std::time::Duration::from_secs(100); |
|
|
| |
| fixture_spinner.reset(); |
|
|
| let actual = fixture_spinner.accumulated_elapsed; |
| let expected = std::time::Duration::ZERO; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_spinner_reset_clears_word_index() { |
| let mut fixture_spinner = fixture_spinner(); |
|
|
| |
| fixture_spinner.word_index = Some(3); |
|
|
| |
| fixture_spinner.reset(); |
|
|
| let actual = fixture_spinner.word_index; |
| let expected = None; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_spinner_reset_clears_message() { |
| let mut fixture_spinner = fixture_spinner(); |
|
|
| |
| fixture_spinner.message = Some("Test".to_string()); |
|
|
| |
| fixture_spinner.reset(); |
|
|
| let actual = fixture_spinner.message.clone(); |
| let expected = None; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[tokio::test] |
| async fn test_word_index_caching_behavior() { |
| let mut fixture_spinner = fixture_spinner(); |
|
|
| |
| fixture_spinner.start(None).unwrap(); |
| let first_index = fixture_spinner.word_index; |
| fixture_spinner.stop(None).unwrap(); |
|
|
| fixture_spinner.start(None).unwrap(); |
| let second_index = fixture_spinner.word_index; |
| fixture_spinner.stop(None).unwrap(); |
|
|
| |
| assert_eq!(first_index, second_index); |
| } |
|
|
| #[test] |
| fn test_spinner_pause_resume_keeps_same_active_spinner() { |
| let mut fixture_spinner = fixture_spinner(); |
| fixture_spinner.start(Some("Thinking")).unwrap(); |
|
|
| fixture_spinner.pause(); |
| let was_paused = fixture_spinner |
| .spinner |
| .as_ref() |
| .map(|spinner| spinner.paused.load(Ordering::Acquire)); |
| fixture_spinner.resume(); |
| let was_resumed = fixture_spinner |
| .spinner |
| .as_ref() |
| .map(|spinner| !spinner.paused.load(Ordering::Acquire)); |
| let actual = (was_paused, was_resumed); |
| fixture_spinner.stop(None).unwrap(); |
|
|
| let expected = (Some(true), Some(true)); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_format_elapsed_time_seconds_only() { |
| let actual = format_elapsed_time(Duration::from_secs(5)); |
| let expected = "05s"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(59)); |
| let expected = "59s"; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_format_elapsed_time_minutes_and_seconds() { |
| let actual = format_elapsed_time(Duration::from_secs(60)); |
| let expected = "1:00m"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(125)); |
| let expected = "2:05m"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(3599)); |
| let expected = "59:59m"; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_format_elapsed_time_hours_and_minutes() { |
| let actual = format_elapsed_time(Duration::from_secs(3600)); |
| let expected = "1:00h"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(3661)); |
| let expected = "1:01h"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(7200)); |
| let expected = "2:00h"; |
| assert_eq!(actual, expected); |
|
|
| let actual = format_elapsed_time(Duration::from_secs(9000)); |
| let expected = "2:30h"; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_format_elapsed_time_zero() { |
| let actual = format_elapsed_time(Duration::ZERO); |
| let expected = "00s"; |
| assert_eq!(actual, expected); |
| } |
| } |
|
|