mod cli;
mod reader;
mod render;
mod sound;
use std::fs::File;
use std::io::{self, BufRead, BufReader, 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 reader::reader_prompt;
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 is_tty = io::stdout().is_terminal();
if opts.read {
if is_tty {
if let Ok(tty) = File::open("/dev/tty") {
run_reader(&input, &opts, tty, opts.sound.as_deref());
return ExitCode::SUCCESS;
}
}
passthrough(&input);
return ExitCode::SUCCESS;
}
let animate = !(opts.fade.is_zero() && opts.stagger.is_zero());
if !animate || !is_tty {
passthrough(&input);
return ExitCode::SUCCESS;
}
let sound = opts.sound.as_deref().and_then(sound::load);
let mut out = io::stdout().lock();
let _ = out.write_all(ENTER);
let _ = out.flush();
let _guard = TermGuard;
reveal_segment(&mut out, &input, &opts, sound.as_ref());
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 increment_has_printable(
snap: &[jiwa::RevealedGrapheme],
prev_visible: usize,
visible: usize,
) -> bool {
if visible <= prev_visible {
return false;
}
snap[prev_visible..visible]
.iter()
.any(|g| !g.text.chars().all(char::is_whitespace))
}
fn reveal_segment<W: Write>(
out: &mut W,
input: &str,
opts: &CliOpts,
sound: Option<&sound::Sound>,
) {
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 {
let _ = out.write_all(input.as_bytes());
if !input.ends_with('\n') {
let _ = out.write_all(b"\n");
}
let _ = out.flush();
return;
}
let frame_delay = Duration::from_secs_f64(1.0 / f64::from(opts.fps));
let mut prev_rows = 0usize;
let mut prev_visible = 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();
if let Some(s) = sound {
if increment_has_printable(&snap, prev_visible, visible) {
s.play();
}
}
prev_visible = visible;
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();
}
fn run_reader(input: &str, opts: &CliOpts, tty: File, sound_spec: Option<&str>) {
let segments = reader::segment(input, opts.by);
if segments.is_empty() {
passthrough(input);
return;
}
let sound = sound_spec.and_then(sound::load);
let mut out = io::stdout().lock();
let _ = out.write_all(ENTER);
let _ = out.flush();
let _guard = TermGuard;
let mut keys = BufReader::new(tty);
let total = segments.len();
for (i, seg) in segments.iter().enumerate() {
reveal_segment(&mut out, seg, opts, sound.as_ref());
if i + 1 == total {
break;
}
let prompt = reader_prompt(i + 1, total);
let _ = out.write_all(prompt.as_bytes());
let _ = out.flush();
let mut line = String::new();
match keys.read_line(&mut line) {
Ok(0) => {
erase_prompt(&mut out, false);
break;
}
Ok(_) => {
let echoed = line.ends_with('\n');
erase_prompt(&mut out, echoed);
if line.trim() == "q" {
break;
}
}
Err(_) => {
erase_prompt(&mut out, false);
break;
}
}
}
}
fn erase_prompt<W: Write>(out: &mut W, echoed_newline: bool) {
if echoed_newline {
let _ = out.write_all(b"\x1b[1A");
}
let _ = out.write_all(b"\r\x1b[J");
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 erase_prompt_steps_up_when_newline_echoed() {
let mut buf = Vec::new();
erase_prompt(&mut buf, true);
assert_eq!(buf, b"\x1b[1A\r\x1b[J".to_vec());
}
#[test]
fn erase_prompt_no_step_up_without_echo() {
let mut buf = Vec::new();
erase_prompt(&mut buf, false);
assert_eq!(buf, b"\r\x1b[J".to_vec());
}
#[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));
}
fn snap_of(text: &str) -> Vec<jiwa::RevealedGrapheme> {
let opts = jiwa::RevealOpts {
char_interval: Duration::ZERO,
fade_duration: Duration::ZERO,
fade_from: Rgb(0, 0, 0),
fade_to: Rgb(255, 255, 255),
};
let now = Instant::now();
jiwa::RevealHandle::start_at(text, opts, now).snapshot(now)
}
#[test]
fn increment_no_progress_is_false() {
let snap = snap_of("ab");
assert!(!increment_has_printable(&snap, 0, 0));
}
#[test]
fn increment_regression_is_false() {
let snap = snap_of("abc");
assert!(!increment_has_printable(&snap, 2, 1));
}
#[test]
fn increment_all_whitespace_is_false() {
let snap = snap_of(" \t\u{3000}\n");
assert!(!increment_has_printable(&snap, 0, snap.len()));
}
#[test]
fn increment_with_one_printable_is_true() {
let snap = snap_of(" x ");
assert!(increment_has_printable(&snap, 0, snap.len()));
}
#[test]
fn increment_single_burst_multiple_is_true() {
let snap = snap_of("hello");
assert!(increment_has_printable(&snap, 0, snap.len()));
}
}