mod cli;
mod render;
use std::io::{self, IsTerminal, Read, Write};
use std::process::ExitCode;
use std::thread;
use std::time::{Duration, Instant};
use jiwa::{RevealHandle, RevealOpts, Rgb};
use cli::{Action, CliOpts};
use render::{plain_text, render_frame, tokenize, visible_newline_rows};
fn main() -> ExitCode {
let args = std::env::args().skip(1);
let action = match cli::parse_args(args) {
Ok(a) => a,
Err(msg) => {
eprintln!("{msg}");
return ExitCode::from(2);
}
};
let opts = match action {
Action::Help => {
print!("{}", cli::USAGE);
return ExitCode::SUCCESS;
}
Action::Version => {
println!("jiwa {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
Action::Run(opts) => opts,
};
let mut input = String::new();
if let Err(e) = io::stdin().read_to_string(&mut input) {
eprintln!("jiwa: failed to read stdin: {e}");
return ExitCode::from(2);
}
let animate = !(opts.fade.is_zero() && opts.stagger.is_zero());
let is_tty = io::stdout().is_terminal();
if !animate || !is_tty {
passthrough(&input);
return ExitCode::SUCCESS;
}
animate_reveal(&input, &opts);
ExitCode::SUCCESS
}
fn passthrough(input: &str) {
let mut out = io::stdout().lock();
let _ = out.write_all(input.as_bytes());
if !input.ends_with('\n') {
let _ = out.write_all(b"\n");
}
let _ = out.flush();
}
const ENTER: &[u8] = b"\x1b[?25l\x1b[?7l";
const RESTORE: &[u8] = b"\x1b[?25h\x1b[?7h";
const AUTOWRAP_ON: &[u8] = b"\x1b[?7h";
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
let mut out = io::stdout().lock();
let _ = out.write_all(RESTORE);
let _ = out.flush();
}
}
fn erase_prev_frame(prev_rows: usize) -> Vec<u8> {
let mut buf = Vec::new();
if prev_rows > 0 {
buf.extend_from_slice(format!("\x1b[{prev_rows}A").as_bytes());
}
buf.extend_from_slice(b"\r\x1b[J");
buf
}
fn final_render_sequence(prev_rows: usize, final_frame: &str) -> Vec<u8> {
let mut buf = erase_prev_frame(prev_rows);
buf.extend_from_slice(AUTOWRAP_ON);
buf.extend_from_slice(final_frame.as_bytes());
buf.push(b'\n');
buf
}
fn animate_reveal(input: &str, opts: &CliOpts) {
let tokens = tokenize(input);
let plain = plain_text(&tokens);
let reveal_opts = RevealOpts {
char_interval: opts.stagger,
fade_duration: opts.fade,
fade_from: opts.from,
fade_to: opts.to,
};
let start = Instant::now();
let handle = RevealHandle::start_at(&plain, reveal_opts, start);
if handle.total_graphemes() == 0 {
passthrough(input);
return;
}
let mut out = io::stdout().lock();
let _ = out.write_all(ENTER);
let _ = out.flush();
let _guard = TermGuard;
let frame_delay = Duration::from_secs_f64(1.0 / f64::from(opts.fps));
let mut prev_rows = 0usize;
let snap = loop {
let now = Instant::now();
let _ = out.write_all(&erase_prev_frame(prev_rows));
let snap = handle.snapshot(now);
let visible = snap.len();
let colors: Vec<Rgb> = snap.iter().map(|g| g.color).collect();
let frame = render_frame(&tokens.tokens, visible, &colors, tokens.has_input_color);
let _ = out.write_all(frame.as_bytes());
prev_rows = visible_newline_rows(&tokens.tokens, visible);
let _ = out.flush();
if handle.is_done(now) {
break snap;
}
thread::sleep(frame_delay);
};
let visible = snap.len();
let colors: Vec<Rgb> = snap.iter().map(|g| g.color).collect();
let final_frame = render_frame(&tokens.tokens, visible, &colors, tokens.has_input_color);
let _ = out.write_all(&final_render_sequence(prev_rows, &final_frame));
let _ = out.flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erase_prev_frame_zero_rows_clears_in_place() {
assert_eq!(erase_prev_frame(0), b"\r\x1b[J".to_vec());
}
#[test]
fn erase_prev_frame_walks_up_then_clears() {
assert_eq!(erase_prev_frame(3), b"\x1b[3A\r\x1b[J".to_vec());
}
#[test]
fn final_render_sequence_reenables_autowrap_before_frame() {
let seq = final_render_sequence(0, "FINAL");
assert_eq!(seq, b"\r\x1b[J\x1b[?7hFINAL\n".to_vec());
let s = String::from_utf8(seq).unwrap();
let autowrap = std::str::from_utf8(AUTOWRAP_ON).unwrap();
let wrap_at = s.find(autowrap).expect("autowrap present");
let frame_at = s.find("FINAL").expect("frame present");
assert!(wrap_at < frame_at, "autowrap must precede the final frame");
}
#[test]
fn final_render_sequence_walks_up_for_prior_rows_and_ends_with_newline() {
let seq = final_render_sequence(2, "X");
assert_eq!(seq, b"\x1b[2A\r\x1b[J\x1b[?7hX\n".to_vec());
assert_eq!(seq.last(), Some(&b'\n'), "must end with a trailing newline");
}
#[test]
fn enter_and_restore_share_autowrap_control() {
assert_eq!(ENTER, b"\x1b[?25l\x1b[?7l");
assert_eq!(RESTORE, b"\x1b[?25h\x1b[?7h");
assert!(RESTORE.ends_with(AUTOWRAP_ON));
}
}