jiwa 0.3.0

Terminal text reveal animations — typewriter + per-grapheme fade-in + pulse. Renderer-agnostic: returns plain RGB so the caller maps to crossterm, ratatui, or its own ANSI writer.
Documentation

jiwa

crates.io docs.rs CI

Terminal text reveal animations for Rust. Two small primitives:

  • Typewriter + per-grapheme fade-in — characters appear one at a time and bloom from one color into another.
  • Pulse — a single symbol "breathes" between two colors on a sinusoidal cycle, useful as a "now playing" / "thinking" indicator.

Both primitives are renderer-agnostic: they return plain Rgb(u8, u8, u8) triples plus the text to draw, and you map those to whatever your renderer already uses (crossterm, ratatui, raw ANSI, etc.). They are also pure — every time-bearing call takes an explicit std::time::Instant, so there is no global clock, no spawned thread, and tests can advance time without sleeping.

The name is from the Japanese onomatopoeia 「じわじわ」 — something appearing slowly, blooming into view.

Install

[dependencies]
jiwa = "0.1"

Example: typewriter reveal

use std::time::{Duration, Instant};
use jiwa::{RevealHandle, RevealOpts};

let start = Instant::now();
let reveal = RevealHandle::start_at("Hello, 世界", RevealOpts::default(), start);

// Tick once per redraw — your event loop chooses the cadence.
let frame = reveal.snapshot(start + Duration::from_millis(100));
for g in &frame {
    // g.text:    the grapheme cluster to draw
    // g.color:   Rgb(u8, u8, u8) to draw it in
    // g.progress: 0.0 (just appeared) .. 1.0 (fade complete)
    print!("{}", g.text);
}

The default preset (RevealOpts::soft_green) is tuned for problem / quiz text on a dark terminal — 45 ms typewriter step, 320 ms fade from a deep gray to a soft green so the in-between color reads as an afterimage rather than a blink.

You can also disable either dimension by zeroing it:

  • char_interval = 0 → whole block fades in together (no typewriter).
  • fade_duration = 0 → pure typewriter, each grapheme appears at its final color.

Example: pulse

use jiwa::{PulseHandle, PulseOpts};

let pulse = PulseHandle::start("", PulseOpts::default());
let frame = pulse.snapshot(std::time::Instant::now());
// frame.text == "♪"
// frame.color cycles between PulseOpts::color_dim and color_bright.

The default preset (PulseOpts::cyan_breath) gives a ~1.5 s breath cycle from a muted teal to a bright cyan, designed for the "♪ audio playing" affordance.

CLI

jiwa also ships a tiny dependency-free binary so the same reveal engine works from a shell pipe:

# whole block fades in
echo "Hello" | jiwa --fade 200ms

# typewriter
echo "Hello" | jiwa --stagger 50ms

# both, with custom colors
echo "Hello" | jiwa --fade 200ms --stagger 50ms --from "#444" --to "#fff"

# value flags also take the `=`-joined form
echo "Hello" | jiwa --fade=200ms --stagger=50ms

# pass-through: existing ANSI color is preserved, reveal is timing only
cat story.txt | lolcat | jiwa --stagger 30ms

When stdout is not a terminal (or no animation flag is given) the input is passed through verbatim, so jiwa ... | other and jiwa ... > file stay free of cursor-control noise. Run jiwa --help for the full flag list.

Reader mode (sound novel)

--read turns a piped novel into an interactive reader: each segment reveals, then jiwa waits for you to press Enter before moving on — the terminal version of a visual-novel "click to continue".

# one sentence at a time (the default), with a gentle typewriter
cat novel.txt | jiwa --read --stagger 40ms

# one paragraph per Enter (paragraphs are split on blank lines — a line
# that is just a newline; a line with only spaces does not start a new one)
cat novel.txt | jiwa --read --by paragraph

# one line per Enter
cat lyrics.txt | jiwa --read --by line --fade 300ms

--by chooses the segment unit: sentence (default), paragraph, or line. The reveal flags (--fade / --stagger / --from / --to / --fps) apply to each segment.

Because the novel occupies stdin, keypresses are read from the controlling terminal (/dev/tty) — the same approach less, fzf, and git add -p use. Press Enter to advance; q or Ctrl-D ends the session. The waiting prompt is erased once you advance, so the scrollback keeps only the novel text. When stdout is not a TTY (or /dev/tty cannot be opened), reader mode falls back to verbatim passthrough so pipes and files stay clean. This is Phase 1: Enter-delimited and dependency-free (no raw mode); single-keypress advance and reveal-skipping are future work.

Sound

--sound <PATH|URL> gives the typewriter a voice: a short sound plays each time a reveal frame brings new non-whitespace text into view (whitespace-only steps stay silent, and it is one play per frame so fast reveals don't spawn a storm of players). Works in reader mode too.

# a local clack on every character
echo "Hello" | jiwa --stagger 60ms --sound ./clack.wav

# fetch a sound once (cached in a temp dir), then reuse it
cat novel.txt | jiwa --read --stagger 40ms --sound https://example.com/blip.wav

jiwa keeps zero dependencies here: it never decodes audio itself. You supply the sound file; playback shells out to whatever OS player is present (ffplay / mpv / aplay / pw-cat, or macOS afplay), and a URL is downloaded once with curl (or wget). The bytes are read once at startup and held in memory — graphemes never re-read or re-download — and each play spawns a fresh, non-blocking process so the animation is never stalled and nothing stays resident.

Everything is best-effort: if the file is missing, no fetcher or player exists, or playback fails, jiwa prints at most one quiet note and reveals silently. WAV is recommended (it plays from stdin cleanly across players); mp3/ogg depend on ffplay/mpv. For recording, let your terminal capture tool record the audio alongside the video — jiwa only produces the sound at the right moment.

Long lines and interrupts

While animating, jiwa redraws each frame in place with line-wrap disabled, so lines wider than the terminal are clipped during the animation; the final confirmed render re-enables line-wrap before drawing, so the permanent output in your scrollback wraps long lines normally. This two-stage approach keeps the in-place animation from scrolling the terminal while still leaving correct, wrapped text behind.

jiwa is intentionally dependency-free and installs no signal handler. If you interrupt an animation with Ctrl-C, the terminal can be left with the cursor hidden and line-wrap turned off. Run reset to restore it.

Mapping to your renderer

jiwa::Rgb is intentionally not a wrapper around crossterm::style::Color or ratatui::style::Color. Map at the call site:

// ratatui
let color = ratatui::style::Color::Rgb(g.color.0, g.color.1, g.color.2);

// crossterm
let color = crossterm::style::Color::Rgb { r: g.color.0, g: g.color.1, b: g.color.2 };

Concurrency note

These primitives are pure functions of (time, text, opts). They do not own a thread, do not poll for input, and do not sleep. Anything your reveal-in-progress UI needs to do concurrently (accept input during the reveal, abort early, restart) is the responsibility of the surrounding event loop — jiwa just answers "what does the frame look like right now?".

Status

  • v0.1.0 — library primitives extracted from type-globe's in-tree jiwa_core module (where they have been used in production since v0.6.0).
  • CLI binaryjiwa reads stdin and reveals it on stdout (echo "Hello" | jiwa --fade 200ms). Dependency-free, pipe-safe. See the CLI section above.

Inspiration

The CLI direction takes cues from TerminalTextEffects (Python). jiwa is intentionally narrower — reveal-shaped effects only, Unix-pipe friendly, Rust single binary.

License

MIT