Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ratatui widget feature #38

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"rust-analyzer.cargo.features": ["ratatui"]
}
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions examples/ratatui.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
31 changes: 20 additions & 11 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,7 +17,7 @@ mod utils;
pub struct Spinner {
sender: Sender<(Instant, Option<String>)>,
join: Option<JoinHandle<()>>,
stream: Stream
stream: Stream,
}

impl Drop for Spinner {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Instant>, stream: Option<Stream>) -> Self
{
fn new_inner(
spinner: Spinners,
message: String,
start_time: Option<Instant>,
stream: Option<Stream>,
) -> 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<String>)>();

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),
Expand All @@ -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;
Expand All @@ -121,7 +128,7 @@ impl Spinner {
Self {
sender,
join: Some(join),
stream
stream,
}
}

Expand Down Expand Up @@ -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<String>) {
Expand Down
2 changes: 2 additions & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(feature = "ratatui")]
pub mod ratatui;
pub mod spinner_data;
pub mod spinner_names;
pub mod spinners_data;
Expand Down
59 changes: 59 additions & 0 deletions src/utils/ratatui.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<Vec<u8>>>,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible that this could have been the shared state instead of the entire widget, but I prefer passing around wrapped shared state values instead of raw values. Do you have preferences one way or another?

}

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<usize> {
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);
}
}
137 changes: 84 additions & 53 deletions src/utils/stream.rs
Original file line number Diff line number Diff line change
@@ -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)]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a breaking change, but I don't think it's possible to implement this without removing Copy.

#[derive(Default, Clone)]
pub enum Stream {
#[default]
Stderr,
Stdout,
#[cfg(feature = "ratatui")]
Ratatui(crate::SpinnerWidget),
}

impl Stream {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of churn in this. In summary:

  • rearranged to read well top to bottom
  • methods which were passed the writer now just get self and pull the writer if needed
  • write calls which would have written ansi / control characters to the stream are now separated from the calls which write info (e.g. \r and the erase line sequence.

/// Matches on self and returns the internal writer
fn match_target(&self) -> Box<dyn Write> {
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<Instant>,
stop_time: Option<Instant>,
) -> 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<dyn Write>,
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<dyn Write>,
frame: &str,
message: &str,
start_time: Instant,
stop_time: Option<Instant>) -> Result<()>
{
fn print_with_duration(
&mut self,
frame: &str,
message: &str,
start_time: Instant,
stop_time: Option<Instant>,
) -> 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<Instant>,
stop_time: Option<Instant>) -> 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<dyn Write> {
match self {
Self::Stderr => Box::new(stderr()),
Self::Stdout => Box::new(stdout()),
#[cfg(feature = "ratatui")]
Self::Ratatui(widget) => Box::new(widget.clone()),
}
}
}