diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb68dde --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.cargo.features": ["ratatui"] +} diff --git a/Cargo.toml b/Cargo.toml index d318b89..95f75bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,16 @@ categories = ["command-line-interface"] license = "MIT" include = ["src/**/*", "README.md"] +[features] +default = [] +ratatui = ["dep:ratatui"] + [dependencies] lazy_static = { version = "1.4.0" } maplit = { version = "1.0.2" } +ratatui = { version = "0.29.0", optional = true } strum = { version = "0.24.0", features = ["derive"] } + +[dev-dependencies] +crossterm = "0.28.1" +ratatui = "0.29.0" diff --git a/examples/ratatui.rs b/examples/ratatui.rs new file mode 100644 index 0000000..06916e7 --- /dev/null +++ b/examples/ratatui.rs @@ -0,0 +1,45 @@ +use std::{ + thread, + time::{Duration, Instant}, +}; + +use ratatui::DefaultTerminal; +use spinners::{Spinner, SpinnerWidget, Spinners}; + +fn main() { + let terminal = ratatui::init(); + let result = run(terminal); + ratatui::restore(); + result.expect("error running ratatui"); +} + +fn run(mut terminal: DefaultTerminal) -> std::io::Result<()> { + let spinner_widget = SpinnerWidget::default(); + let mut spinner = Spinner::with_stream( + Spinners::Dots9, + "Waiting for 3 seconds".into(), + spinner_widget.as_steam(), + ); + + const FPS: f64 = 60.0; + let frame_duration = Duration::from_secs_f64(1.0 / FPS); + + // Normally a Rataui app would have a loop that interacts with user events, but for this example + // we just want to show the spinner for 3 seconds, then stop it, making sure to render the + // spinner in the terminal each frame + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + terminal.draw(|frame| frame.render_widget(&spinner_widget, frame.area()))?; + thread::sleep(frame_duration); + } + + spinner.stop_with_message("Finishing waiting for 3 seconds\n".into()); + + // show the finished spinner for 3 more seconds + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + terminal.draw(|frame| frame.render_widget(&spinner_widget, frame.area()))?; + thread::sleep(frame_duration); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 9dc456e..65823b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ use std::{ time::Duration, }; +#[cfg(feature = "ratatui")] +pub use crate::utils::ratatui::SpinnerWidget; pub use crate::utils::spinner_names::SpinnerNames as Spinners; use crate::utils::spinners_data::SPINNERS as SpinnersMap; pub use crate::utils::stream::Stream; @@ -15,7 +17,7 @@ mod utils; pub struct Spinner { sender: Sender<(Instant, Option)>, join: Option>, - stream: Stream + stream: Stream, } impl Drop for Spinner { @@ -64,7 +66,7 @@ impl Spinner { /// /// ``` /// use spinners::{Spinner, Spinners, Stream}; - /// + /// /// let sp = Spinner::with_stream(Spinners::Dots, String::new(), Stream::Stderr); /// ``` pub fn with_stream(spinner: Spinners, message: String, stream: Stream) -> Self { @@ -79,26 +81,29 @@ impl Spinner { /// /// ``` /// use spinners::{Spinner, Spinners, Stream}; - /// + /// /// let sp = Spinner::with_timer_and_stream(Spinners::Dots, String::new(), Stream::Stderr); /// ``` pub fn with_timer_and_stream(spinner: Spinners, message: String, stream: Stream) -> Self { Self::new_inner(spinner, message, Some(Instant::now()), Some(stream)) } - fn new_inner(spinner: Spinners, message: String, start_time: Option, stream: Option) -> Self - { + fn new_inner( + spinner: Spinners, + message: String, + start_time: Option, + stream: Option, + ) -> Self { let spinner_name = spinner.to_string(); let spinner_data = SpinnersMap .get(&spinner_name) .unwrap_or_else(|| panic!("No Spinner found with the given name: {}", spinner_name)); - let stream = if let Some(stream) = stream { stream } else { Stream::default() }; - + let stream = stream.unwrap_or_default(); + let mut stream_clone = stream.clone(); let (sender, recv) = channel::<(Instant, Option)>(); let join = thread::spawn(move || 'outer: loop { - for frame in spinner_data.frames.iter() { let (do_stop, stop_time, stop_symbol) = match recv.try_recv() { Ok((stop_time, stop_symbol)) => (true, Some(stop_time), stop_symbol), @@ -108,7 +113,9 @@ impl Spinner { let frame = stop_symbol.unwrap_or_else(|| frame.to_string()); - stream.write(&frame, &message, start_time, stop_time).expect("IO Error"); + stream_clone + .write(&frame, &message, start_time, stop_time) + .expect("IO Error"); if do_stop { break 'outer; @@ -121,7 +128,7 @@ impl Spinner { Self { sender, join: Some(join), - stream + stream, } } @@ -232,7 +239,9 @@ impl Spinner { /// ``` pub fn stop_and_persist(&mut self, symbol: &str, msg: String) { self.stop(); - self.stream.stop(Some(&msg), Some(symbol)).expect("IO Error"); + self.stream + .stop(Some(&msg), Some(symbol)) + .expect("IO Error"); } fn stop_inner(&mut self, stop_time: Instant, stop_symbol: Option) { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 6f2b356..5009e56 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "ratatui")] +pub mod ratatui; pub mod spinner_data; pub mod spinner_names; pub mod spinners_data; diff --git a/src/utils/ratatui.rs b/src/utils/ratatui.rs new file mode 100644 index 0000000..ce53fc1 --- /dev/null +++ b/src/utils/ratatui.rs @@ -0,0 +1,59 @@ +use std::{ + io::{Result, Write}, + sync::{Arc, Mutex}, +}; + +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + +use crate::Stream; + +/// A ratatui widget that can be used to display a spinner +/// +/// # Examples +/// +/// ```no_run +/// use ratatui::widgets::SpinnerWidget; +/// +/// let spinner_widget = SpinnerWidget::default(); +/// let spinner = Spinner::with_stream( +/// Spinners::Dots9, +/// "Waiting ".into(), +/// spinner_widget.as_steam(), +/// ); +/// # let area = Rect::default(); +/// # let buf = &mut Buffer::empty(area); +/// spinner_widget.render(area, buf); +/// ``` +#[derive(Clone, Default)] +pub struct SpinnerWidget { + value: Arc>>, +} + +impl SpinnerWidget { + pub(crate) fn clear(&self) { + self.value.lock().unwrap().clear(); + } + + pub fn as_steam(&self) -> Stream { + Stream::Ratatui(self.clone()) + } +} + +impl Write for SpinnerWidget { + fn write(&mut self, buf: &[u8]) -> Result { + let mut vec = self.value.lock().unwrap(); + vec.write(buf) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +impl Widget for &SpinnerWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + let value = self.value.lock().unwrap(); + let value = std::str::from_utf8(&value).unwrap_or_default(); + value.render(area, buf); + } +} diff --git a/src/utils/stream.rs b/src/utils/stream.rs index ac2a38a..f36cbf3 100644 --- a/src/utils/stream.rs +++ b/src/utils/stream.rs @@ -1,80 +1,111 @@ -use std::{io::{Write, stdout, stderr, Result}, time::Instant}; +use std::{ + io::{stderr, stdout, Result, Write}, + time::Instant, +}; /// Handles the Printing logic for the Spinner -#[derive(Default, Copy, Clone)] +#[derive(Default, Clone)] pub enum Stream { #[default] Stderr, Stdout, + #[cfg(feature = "ratatui")] + Ratatui(crate::SpinnerWidget), } + impl Stream { - /// Matches on self and returns the internal writer - fn match_target(&self) -> Box { - match self { - Self::Stderr => Box::new(stderr()), - Self::Stdout => Box::new(stdout()) + /// Writes the current message and optionally prints the durations + pub fn write( + &mut self, + frame: &str, + message: &str, + start_time: Option, + stop_time: Option, + ) -> Result<()> { + match start_time { + None => self.print(frame, message)?, + Some(start_time) => self.print_with_duration(frame, message, start_time, stop_time)?, } + Ok(()) } /// Writes the message without duration - fn print_message( - writer: &mut Box, - frame: &str, - message: &str) -> Result<()> - { - write!(writer, "\r{} {}", frame, message)?; + fn print(&mut self, frame: &str, message: &str) -> Result<()> { + let mut writer = self.writer(); + self.start_of_line()?; + write!(writer, "{frame} {message}")?; writer.flush() } /// Writes the message with the duration - fn print_message_with_duration( - writer: &mut Box, - frame: &str, - message: &str, - start_time: Instant, - stop_time: Option) -> Result<()> - { + fn print_with_duration( + &mut self, + frame: &str, + message: &str, + start_time: Instant, + stop_time: Option, + ) -> Result<()> { + let mut writer = self.writer(); let now = stop_time.unwrap_or_else(Instant::now); let duration = now.duration_since(start_time).as_secs_f64(); - write!(writer, "\r{}{:>10.3} s\t{}", frame, duration, message)?; + write!(writer, "\r{frame}{duration:>10.3} s\t{message}")?; writer.flush() } - /// Writes the current message and optionally prints the durations - pub fn write( - &self, - frame: &str, - message: &str, - start_time: Option, - stop_time: Option) -> Result<()> - { - let mut writer = self.match_target(); - match start_time { - None => Self::print_message( - &mut writer, frame, message)?, - Some(start_time) => Self::print_message_with_duration( - &mut writer, frame, message, start_time, stop_time)? - }; - Ok(()) - } - /// Handles the stopping logic given an optional message and symbol - pub fn stop( - &self, - message: Option<&str>, - symbol: Option<&str>) -> Result<()> - { - let mut writer = self.match_target(); + pub fn stop(&self, message: Option<&str>, symbol: Option<&str>) -> Result<()> { + let mut writer = self.writer(); match (message, symbol) { - // persist with symbol and message - (Some(m), Some(s)) => writeln!(writer, "\x1b[2K\r{} {}", s, m), - - // persist with message only - (Some(m), None) => writeln!(writer, "\x1b[2K\r{}", m), - - // simple newline - _ => writeln!(writer) + (Some(message), Some(symbol)) => { + self.erase_line()?; + writeln!(writer, "{symbol} {message}") + } + (Some(message), None) => { + self.erase_line()?; + writeln!(writer, "{message}") + } + _ => writeln!(writer), }?; writer.flush() } + + fn start_of_line(&self) -> Result<()> { + match self { + #[cfg(feature = "ratatui")] + Self::Ratatui(widget) => { + widget.clear(); + Ok(()) + } + _ => { + let mut writer = self.writer(); + write!(writer, "\r")?; + writer.flush() + } + } + } + + fn erase_line(&self) -> Result<()> { + match self { + #[cfg(feature = "ratatui")] + Self::Ratatui(widget) => { + widget.clear(); + Ok(()) + } + _ => { + let mut writer = self.writer(); + write!(writer, "\x1b[2K\r")?; + writer.flush() + } + } + } + + /// Matches on self and returns the internal writer + fn writer(&self) -> Box { + match self { + Self::Stderr => Box::new(stderr()), + Self::Stdout => Box::new(stdout()), + #[cfg(feature = "ratatui")] + Self::Ratatui(widget) => Box::new(widget.clone()), + } + } }