From 20933e71a78975cbcaf7621107423c06f62da8e7 Mon Sep 17 00:00:00 2001 From: Sam Tay Date: Wed, 1 Jul 2020 22:35:19 -0700 Subject: Add a CLI spinner --- TODO.md | 2 -- src/main.rs | 15 +++++---- src/term.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/utils.rs | 22 ------------ 4 files changed, 113 insertions(+), 33 deletions(-) diff --git a/TODO.md b/TODO.md index 6047985..e6456cd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,6 @@ # TODO ### chores -0. Add a simpler loading spinner during --lucky retrieval and between SPACE and - TUI. 0. Use `include_str!` instead of hardcoding `colors.toml`. 1. Move to github actions ASAP, travis & appveyor are a PITA. See resources below. 2. Refactor layout handling (see TODO on `tui::views::LayoutView::relayout`) diff --git a/src/main.rs b/src/main.rs index 7ce78a1..2db6c99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,6 @@ use tokio::task; use error::{Error, Result}; use stackexchange::{LocalStorage, Question, Search}; -use term::mk_print_error; use tui::markdown::Markdown; fn main() -> Result<()> { @@ -23,7 +22,7 @@ fn main() -> Result<()> { let mut skin = MadSkin::default(); skin.inline_code = CompoundStyle::with_fg(Color::Cyan); skin.code_block.compound_style = CompoundStyle::with_fg(Color::Cyan); - let mut print_error = mk_print_error(&skin); + let mut print_error = term::mk_print_error(&skin); // Tokio runtime let mut rt = Runtime::new()?; @@ -84,17 +83,21 @@ async fn run(skin: &mut MadSkin) -> Result>>> { if let Some(q) = opts.query { let mut search = Search::new(config, ls, q); if lucky { - let md = search.search_lucky().await?; + // Show top answer + let md = term::wrap_spinner(search.search_lucky()).await??; skin.print_text(&md); skin.print_text("\nPress **[SPACE]** to see more results, or any other key to exit"); + // Kick off the rest of the search in the background let qs = task::spawn(async move { search.search_md().await }); - if !utils::wait_for_char(' ')? { + if !term::wait_for_char(' ')? { return Ok(None); } - return Ok(Some(qs.await.unwrap()?)); + + // Get the rest of the questions + return Ok(Some(term::wrap_spinner(qs).await?.unwrap()?)); } else { - return Ok(Some(search.search_md().await?)); + return Ok(Some(term::wrap_spinner(search.search_md()).await??)); } } Ok(None) diff --git a/src/term.rs b/src/term.rs index 8297a6a..9ad880b 100644 --- a/src/term.rs +++ b/src/term.rs @@ -1,10 +1,111 @@ -use crate::error::{Error, Result}; -use crossterm::style::Color; +use crossterm::event::{read, Event, KeyCode, KeyEvent}; +use crossterm::style::{Color, Print}; +use crossterm::terminal::ClearType; +use crossterm::{cursor, execute, terminal}; +use futures::Future; use lazy_static::lazy_static; use minimad::mad_inline; -use std::io::Stderr; +use std::io::{stderr, Stderr, Write}; use termimad::{mad_write_inline, MadSkin}; +use tokio::sync::{ + oneshot, + oneshot::{error::TryRecvError, Sender}, +}; +use tokio::task::JoinHandle; +use tokio::time; + +use crate::error::{Error, Result}; + +const LOADING_SPINNER_DELAY: u64 = 40; +const LOADING_SPINNER_DOTS: [&str; 56] = [ + "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", + "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", "⠍⡐", "⢋⠠", + "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", "⠀⢙", "⠀⡙", "⠀⠩", + "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀", +]; + +/// Blocks and waits for the user to press any key. Returns whether or not that key is the +/// character key `c`. +pub fn wait_for_char(c: char) -> Result { + let mut pressed = false; + terminal::enable_raw_mode()?; + loop { + match read()? { + Event::Key(KeyEvent { + code: KeyCode::Char(ch), + .. + }) if ch == c => { + pressed = true; + break; + } + Event::Key(_) => break, + _ => (), + } + } + terminal::disable_raw_mode()?; + Ok(pressed) +} + +/// As it sounds, takes a future and shows a CLI spinner until it's output is ready +pub async fn wrap_spinner(future: F) -> Result +where + F: Future, +{ + // Start spinner + let (tx, spinner_handle) = spinner(); + + let result = future.await; + + // Stop spinner + tx.send(()).ok(); + spinner_handle.await??; + + Ok(result) +} + +/// Start a CLI spinner on the current cursor line. To stop it, call `send` on the `Sender`. To +/// wait until it's done cleaning up it's current action (which is very important), await it's +/// `JoinHandle`. +pub fn spinner() -> (Sender<()>, JoinHandle>) { + let (tx, mut rx) = oneshot::channel(); + let spinner_handle = tokio::spawn(async move { + let mut dots = LOADING_SPINNER_DOTS.iter().cycle(); + terminal::enable_raw_mode()?; + execute!( + stderr(), + cursor::SavePosition, + cursor::Hide, + terminal::Clear(ClearType::CurrentLine), + )?; + let mut interval = time::interval(time::Duration::from_millis(LOADING_SPINNER_DELAY)); + loop { + match rx.try_recv() { + Err(TryRecvError::Empty) => { + execute!( + stderr(), + cursor::MoveToColumn(0), + terminal::Clear(ClearType::CurrentLine), + Print(dots.next().unwrap()) + )?; + interval.tick().await; + } + _ => break, + } + } + execute!( + stderr(), + terminal::Clear(ClearType::CurrentLine), + cursor::RestorePosition, + cursor::Show, + )?; + terminal::disable_raw_mode()?; + Ok(()) + }); + (tx, spinner_handle) +} +/// Temporarily modifies a skin with error styles (e.g. red fg) for use with the given closure. +/// Once the closure finishes, the skin is returned to original state. pub fn with_error_style(skin: &mut MadSkin, f: F) -> Result where F: FnOnce(&MadSkin, &mut Stderr) -> Result, diff --git a/src/utils.rs b/src/utils.rs index e7a8830..d1ffed8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,4 @@ use crate::error::{Error, PermissionType, Result}; -use crossterm::event::{read, Event, KeyCode, KeyEvent}; -use crossterm::terminal; use std::fs::File; use std::io::ErrorKind; use std::path::PathBuf; @@ -24,23 +22,3 @@ pub fn create_file(filename: &PathBuf) -> Result { } }) } - -pub fn wait_for_char(c: char) -> Result { - let mut pressed = false; - terminal::enable_raw_mode()?; - loop { - match read()? { - Event::Key(KeyEvent { - code: KeyCode::Char(ch), - .. - }) if ch == c => { - pressed = true; - break; - } - Event::Key(_) => break, - _ => (), - } - } - terminal::disable_raw_mode()?; - Ok(pressed) -} -- cgit v1.2.3