diff --git a/Cargo.lock b/Cargo.lock index 88e6eae..892d616 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,7 @@ dependencies = [ "colored", "reqwest", "serde", + "termion", "tokio", ] @@ -663,6 +664,17 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -740,6 +752,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + [[package]] name = "object" version = "0.36.7" @@ -879,6 +897,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + [[package]] name = "reqwest" version = "0.12.15" @@ -1212,6 +1236,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termion" +version = "4.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3669a69de26799d6321a5aa713f55f7e2cd37bd47be044b50f2acafc42c122bb" +dependencies = [ + "libc", + "libredox", + "numtoa", + "redox_termios", +] + [[package]] name = "tinystr" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 080cb87..0c362dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,6 @@ authors = ["Bible CLI App"] reqwest = { version = "0.12.15", features = ["json"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } -clap = "4.4" -colored = "3.0.0" \ No newline at end of file +clap = { version = "4.4", features = ["cargo"] } +colored = "3.0.0" +termion = "4.0.5" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7a21bba..17dc718 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,19 @@ + use clap::Command; -use colored::*; +use colored::{Color, Colorize}; use serde::{Deserialize, Serialize}; use std::error::Error; +use std::io::{stdout, Write}; +use std::sync::{Arc, Mutex}; +use termion::{clear, cursor, event::Key, input::TermRead, raw::IntoRawMode, terminal_size}; +use tokio::time::{sleep, Duration}; + +const BORDER_COLOR: Color = Color::TrueColor { r: 117, g: 142, b: 205 }; // #758ECD +const REF_COLOR: Color = Color::TrueColor { r: 113, g: 137, b: 255 }; // #7189FF +const TEXT_COLOR: Color = Color::TrueColor { r: 193, g: 206, b: 254 }; // #C1CEFE +const ATTR_COLOR: Color = Color::TrueColor { r: 160, g: 221, b: 255 }; // #A0DDFF +const NEXT_BTN_COLOR: Color = Color::TrueColor { r: 98, g: 76, b: 171 }; // #624CAB +const QUIT_BTN_COLOR: Color = Color::TrueColor { r: 160, g: 221, b: 255 }; // #A0DDFF #[derive(Debug, Serialize, Deserialize)] struct BibleVerse { @@ -33,24 +45,149 @@ async fn fetch_random_verse() -> Result> { Ok(verse) } -fn display_verse(verse: &BibleVerse) { - println!("{}", "═".color("cyan").repeat(60)); +fn word_wrap(text: &str, max_width: usize) -> Vec { + let mut result = Vec::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.len() + word.len() + 1 > max_width { + result.push(current_line); + current_line = word.to_string(); + } else { + if !current_line.is_empty() { + current_line.push(' '); + } + current_line.push_str(word); + } + } + + if !current_line.is_empty() { + result.push(current_line); + } + + result +} + +fn display_verse(verse: &BibleVerse, term_width: u16, term_height: u16) { + let max_width = std::cmp::min(80, term_width as usize - 10); + let box_width = max_width + 8; - // Create a reference string like "Book chapter:verse" let reference = format!("{} {}:{}", - verse.random_verse.book, - verse.random_verse.chapter, - verse.random_verse.verse); + verse.random_verse.book, + verse.random_verse.chapter, + verse.random_verse.verse); - println!("{}", reference.bold().color("yellow")); - println!(); - println!("{}", verse.random_verse.text.trim()); - println!(); - println!("─ {} ({}) [{}]", - verse.translation.name.italic().color("green"), - verse.translation.identifier.italic(), - verse.translation.language); - println!("{}", "═".color("cyan").repeat(60)); + let wrapped_text = word_wrap(&verse.random_verse.text, max_width - 4); + let attribution = format!("{} ({})", verse.translation.name, verse.translation.identifier); + + let box_height = wrapped_text.len() + 7; + let start_y = (term_height as usize / 2).saturating_sub(box_height / 2); + let start_x = (term_width as usize / 2).saturating_sub(box_width / 2); + + print!("{}{}", clear::All, cursor::Goto(1, 1)); + + print!("{}", cursor::Goto(start_x as u16, start_y as u16)); + println!( + "{}{}{}", + "╔".color(BORDER_COLOR).bold(), + "═".repeat(box_width - 2).color(BORDER_COLOR).bold(), + "╗".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16, (start_y + 1) as u16)); + println!( + "{}{}{}", + "║".color(BORDER_COLOR).bold(), + " ".repeat(box_width - 2), + "║".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16, (start_y + 2) as u16)); + let centered_ref = format!("{:^width$}", reference, width = box_width - 2); + println!( + "{}{}{}", + "║".color(BORDER_COLOR).bold(), + centered_ref.color(REF_COLOR).bold(), + "║".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16, (start_y + 3) as u16)); + println!( + "{}{}{}", + "║".color(BORDER_COLOR).bold(), + " ".repeat(box_width - 2), + "║".color(BORDER_COLOR).bold() + ); + + for (i, line) in wrapped_text.iter().enumerate() { + print!("{}", cursor::Goto(start_x as u16, (start_y + 4 + i) as u16)); + let padding = (box_width - 2 - line.len()) / 2; + let left_padding = " ".repeat(padding); + let right_padding = " ".repeat(box_width - 2 - padding - line.len()); + println!( + "{}{}{}{}", + "║".color(BORDER_COLOR).bold(), + left_padding, + line.color(TEXT_COLOR), + format!("{}{}", right_padding, "║").color(BORDER_COLOR).bold() + ); + } + + print!("{}", cursor::Goto(start_x as u16, (start_y + 4 + wrapped_text.len()) as u16)); + println!( + "{}{}{}", + "║".color(BORDER_COLOR).bold(), + " ".repeat(box_width - 2), + "║".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16, (start_y + 5 + wrapped_text.len()) as u16)); + let centered_attr = format!("{:^width$}", attribution, width = box_width - 2); + println!( + "{}{}{}", + "║".color(BORDER_COLOR).bold(), + centered_attr.color(ATTR_COLOR).italic(), + "║".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16, (start_y + 6 + wrapped_text.len()) as u16)); + println!( + "{}{}{}", + "╚".color(BORDER_COLOR).bold(), + "═".repeat(box_width - 2).color(BORDER_COLOR).bold(), + "╝".color(BORDER_COLOR).bold() + ); + + print!("{}", cursor::Goto(start_x as u16 + 4, (start_y + 8 + wrapped_text.len()) as u16)); + print!("{}", "[N]ext Verse".color(NEXT_BTN_COLOR).bold()); + + print!("{}", cursor::Goto(start_x as u16 + box_width as u16 - 15, (start_y + 8 + wrapped_text.len()) as u16)); + print!("{}", "[Q]uit".color(QUIT_BTN_COLOR).bold()); + + print!("{}", cursor::Goto(start_x as u16 + (box_width / 2) as u16 - 15, (start_y + 10 + wrapped_text.len()) as u16)); + print!("{}", "Press N for next verse or Q to quit".color(REF_COLOR)); + + stdout().flush().unwrap(); +} + +async fn display_loading_animation(loading: Arc>, term_width: u16, term_height: u16) { + let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let mut frame_index = 0; + let message = "Loading Bible verse"; + let message_len = message.len() + 2; + + while *loading.lock().unwrap() { + let spinner = spinner_frames[frame_index].color(NEXT_BTN_COLOR).bold(); + let x_pos = (term_width as usize / 2).saturating_sub(message_len / 2); + let y_pos = term_height as usize / 2; + + print!("{}", cursor::Goto(x_pos as u16, y_pos as u16)); + print!("{} {}", spinner, message.color(REF_COLOR).bold()); + stdout().flush().unwrap(); + + frame_index = (frame_index + 1) % spinner_frames.len(); + sleep(Duration::from_millis(80)).await; + } } #[tokio::main] @@ -58,20 +195,72 @@ async fn main() -> Result<(), Box> { let _matches = Command::new("Bible Verse") .version("1.0") .author("BibleVerse") - .about("Fetches random Bible verses") + .about("A beautiful Bible verse display") .get_matches(); - println!("Fetching a random Bible verse..."); + let mut stdout = stdout().into_raw_mode()?; + write!(stdout, "{}", clear::All)?; + stdout.flush()?; - match fetch_random_verse().await { - Ok(verse) => { - display_verse(&verse); - } - Err(e) => { - eprintln!("{} {}", "Error:".bold().color("red"), e); - eprintln!("Try checking your internet connection or the API endpoint."); + let mut stdin = termion::async_stdin().keys(); + let (term_width, term_height) = terminal_size()?; + let loading = Arc::new(Mutex::new(true)); + let loading_clone = loading.clone(); + + let animation_task = tokio::spawn(async move { + display_loading_animation(loading_clone, term_width, term_height).await; + }); + + let mut current_verse = fetch_random_verse().await?; + + *loading.lock().unwrap() = false; + animation_task.await?; + + loop { + let (term_width, term_height) = terminal_size()?; + display_verse(¤t_verse, term_width, term_height); + + let mut key_pressed = false; + while !key_pressed { + if let Some(Ok(key)) = stdin.next() { + match key { + Key::Char('q') | Key::Char('Q') | Key::Esc => { + write!(stdout, "{}{}", clear::All, cursor::Show)?; + stdout.flush()?; + return Ok(()); + } + Key::Char('n') | Key::Char('N') | Key::Char(' ') => { + key_pressed = true; + let loading = Arc::new(Mutex::new(true)); + let loading_clone = loading.clone(); + write!(stdout, "{}", clear::All)?; + stdout.flush()?; + + let animation_task = tokio::spawn(async move { + display_loading_animation(loading_clone, term_width, term_height).await; + }); + + match fetch_random_verse().await { + Ok(verse) => { + *loading.lock().unwrap() = false; + animation_task.await?; + current_verse = verse; + } + Err(e) => { + *loading.lock().unwrap() = false; + animation_task.await?; + write!(stdout, "{}{}", clear::All, cursor::Show)?; + stdout.flush()?; + eprintln!("{} {}", "Error fetching verse:".bold().color(QUIT_BTN_COLOR), e); + return Err(e); + } + } + } + _ => {} + } + } + + tokio::time::sleep(Duration::from_millis(50)).await; } } - - Ok(()) -} \ No newline at end of file +}