use std::{sync::Arc, time::Duration};
use crossterm::terminal;
use r3bl_ansi_color::{is_fully_uninteractive_terminal,
is_stdout_piped,
StdoutIsPipedResult,
TTYResult};
use r3bl_core::{LineStateControlSignal, SharedWriter};
use tokio::time::interval;
use crate::{spinner_render, SafeBool, SafeRawTerminal, SpinnerStyle, StdMutex};
pub struct Spinner {
pub tick_delay: Duration,
pub message: String,
pub style: SpinnerStyle,
pub safe_output_terminal: SafeRawTerminal,
pub shared_writer: SharedWriter,
pub shutdown_sender: tokio::sync::broadcast::Sender<()>,
safe_is_shutdown: SafeBool,
}
impl Spinner {
pub async fn try_start(
spinner_message: String,
tick_delay: Duration,
style: SpinnerStyle,
safe_output_terminal: SafeRawTerminal,
shared_writer: SharedWriter,
) -> miette::Result<Option<Spinner>> {
if let StdoutIsPipedResult::StdoutIsPiped = is_stdout_piped() {
return Ok(None);
}
if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() {
return Ok(None);
}
let (shutdown_sender, _) = tokio::sync::broadcast::channel::<()>(1);
let mut spinner = Spinner {
message: spinner_message,
tick_delay,
style,
safe_output_terminal,
shared_writer,
shutdown_sender,
safe_is_shutdown: Arc::new(StdMutex::new(false)),
};
spinner.try_start_task().await?;
Ok(Some(spinner))
}
pub fn is_shutdown(&self) -> bool { *self.safe_is_shutdown.lock().unwrap() }
async fn try_start_task(&mut self) -> miette::Result<()> {
_ = self
.shared_writer
.line_state_control_channel_sender
.send(LineStateControlSignal::SpinnerActive(
self.shutdown_sender.clone(),
))
.await;
let _ = self
.shared_writer
.line_state_control_channel_sender
.send(LineStateControlSignal::Pause)
.await;
let message = self.message.clone();
let tick_delay = self.tick_delay;
let mut style = self.style.clone();
let safe_output_terminal = self.safe_output_terminal.clone();
let mut shutdown_receiver = self.shutdown_sender.subscribe();
let self_safe_is_shutdown = self.safe_is_shutdown.clone();
tokio::spawn(async move {
let mut interval = interval(tick_delay);
let mut count = 0;
let message_clone = message.clone();
loop {
tokio::select! {
_ = interval.tick() => {
let output = spinner_render::render_tick(
&mut style,
&message_clone,
count,
get_terminal_display_width()
);
let _ = spinner_render::print_tick(
&style,
&output,
&mut (*safe_output_terminal.lock().unwrap())
);
count += 1;
},
_ = shutdown_receiver.recv() => {
*self_safe_is_shutdown.lock().unwrap() = true;
break;
}
}
}
});
Ok(())
}
pub async fn stop(&mut self, final_message: &str) -> miette::Result<()> {
_ = self
.shared_writer
.line_state_control_channel_sender
.send(LineStateControlSignal::SpinnerInactive)
.await;
if !*self.safe_is_shutdown.lock().unwrap() {
_ = self.shutdown_sender.send(());
}
let final_output = spinner_render::render_final_tick(
&self.style,
final_message,
get_terminal_display_width(),
);
spinner_render::print_final_tick(
&self.style,
&final_output,
&mut *self.safe_output_terminal.clone().lock().unwrap(),
)?;
let _ = self
.shared_writer
.line_state_control_channel_sender
.send(LineStateControlSignal::Resume)
.await;
Ok(())
}
}
fn get_terminal_display_width() -> usize {
match terminal::size() {
Ok((columns, _rows)) => columns as usize,
Err(_) => 0,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use r3bl_test_fixtures::StdoutMock;
use super::{is_fully_uninteractive_terminal,
Duration,
LineStateControlSignal,
SharedWriter,
Spinner,
SpinnerStyle,
TTYResult};
use crate::{SpinnerColor, StdMutex};
#[tokio::test]
#[allow(clippy::needless_return)]
async fn test_spinner_color() {
let stdout_mock = StdoutMock::default();
let safe_output_terminal = Arc::new(StdMutex::new(stdout_mock.clone()));
let (line_sender, mut line_receiver) = tokio::sync::mpsc::channel(1_000);
let shared_writer = SharedWriter::new(line_sender);
let quantum = Duration::from_millis(100);
let spinner = Spinner::try_start(
"message".to_string(),
quantum,
SpinnerStyle {
template: crate::SpinnerTemplate::Braille,
color: SpinnerColor::None,
},
safe_output_terminal,
shared_writer,
)
.await;
if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() {
return;
}
let mut spinner = spinner.unwrap().unwrap();
tokio::time::sleep(quantum * 5).await;
spinner.stop("final message").await.unwrap();
let output_buffer_data = stdout_mock.get_copy_of_buffer_as_string_strip_ansi();
assert!(output_buffer_data.contains("final message"));
assert_eq!(
output_buffer_data,
"⠁ message\n⠃ message\n⡇ message\n⠇ message\n⡎ message\nfinal message\n"
);
let line_control_signal_sink = {
let mut acc = vec![];
loop {
let it = line_receiver.try_recv();
match it {
Ok(signal) => {
acc.push(signal);
}
Err(error) => match error {
tokio::sync::mpsc::error::TryRecvError::Empty => {
break;
}
tokio::sync::mpsc::error::TryRecvError::Disconnected => {
break;
}
},
}
}
acc
};
assert_eq!(line_control_signal_sink.len(), 4);
matches!(
line_control_signal_sink[0],
LineStateControlSignal::SpinnerActive(_)
);
matches!(line_control_signal_sink[1], LineStateControlSignal::Pause);
matches!(
line_control_signal_sink[2],
LineStateControlSignal::SpinnerInactive
);
matches!(line_control_signal_sink[3], LineStateControlSignal::Resume);
drop(line_receiver);
}
#[tokio::test]
#[allow(clippy::needless_return)]
async fn test_spinner_no_color() {
let stdout_mock = StdoutMock::default();
let safe_output_terminal = Arc::new(StdMutex::new(stdout_mock.clone()));
let (line_sender, mut line_receiver) = tokio::sync::mpsc::channel(1_000);
let shared_writer = SharedWriter::new(line_sender);
let quantum = Duration::from_millis(100);
let spinner = Spinner::try_start(
"message".to_string(),
quantum,
SpinnerStyle::default(),
safe_output_terminal,
shared_writer,
)
.await;
if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() {
return;
}
let mut spinner = spinner.unwrap().unwrap();
tokio::time::sleep(quantum * 5).await;
spinner.stop("final message").await.unwrap();
let output_buffer_data = stdout_mock.get_copy_of_buffer_as_string();
assert!(output_buffer_data.contains("final message"));
assert_ne!(
output_buffer_data,
"⠁ message\n⠃ message\n⡇ message\n⠇ message\n⡎ message\nfinal message\n"
);
assert!(output_buffer_data.contains(
"\u{1b}[1G\u{1b}[2K\u{1b}[38;2;18;194;233m⠁\u{1b}[39m \u{1b}[38;2;18;194;233mmessage"
));
assert!(output_buffer_data
.contains("\u{1b}[39m\n\u{1b}[1A\u{1b}[1G\u{1b}[2Kfinal message\n"));
let line_control_signal_sink = {
let mut acc = vec![];
loop {
let it = line_receiver.try_recv();
match it {
Ok(signal) => {
acc.push(signal);
}
Err(error) => match error {
tokio::sync::mpsc::error::TryRecvError::Empty => {
break;
}
tokio::sync::mpsc::error::TryRecvError::Disconnected => {
break;
}
},
}
}
acc
};
assert_eq!(line_control_signal_sink.len(), 4);
matches!(
line_control_signal_sink[0],
LineStateControlSignal::SpinnerActive(_)
);
matches!(line_control_signal_sink[1], LineStateControlSignal::Pause);
matches!(
line_control_signal_sink[2],
LineStateControlSignal::SpinnerInactive
);
matches!(line_control_signal_sink[3], LineStateControlSignal::Resume);
drop(line_receiver);
}
}