# jiwa
[](https://crates.io/crates/jiwa)
[](https://docs.rs/jiwa)
[](https://github.com/kako-jun/jiwa/actions/workflows/ci.yml)
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
```toml
[dependencies]
jiwa = "0.1"
```
## Example: typewriter reveal
```rust
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
```rust
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:
```sh
# whole block fades in
# typewriter
# both, with custom colors
# value flags also take the `=`-joined form
# pass-through: existing ANSI color is preserved, reveal is timing only
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".
```sh
# one sentence at a time (the default), with a gentle typewriter
# 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)
# one line per Enter
`--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.
```sh
# a local clack on every character
# fetch a sound once (cached in a temp dir), then reuse it
`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:
```rust
// 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](https://github.com/kako-jun/type-globe)'s in-tree
`jiwa_core` module (where they have been used in production since
v0.6.0).
- **CLI binary** — `jiwa` reads stdin and reveals it on stdout
(`echo "Hello" | jiwa --fade 200ms`). Dependency-free, pipe-safe. See
the [CLI](#cli) section above.
## Inspiration
The CLI direction takes cues from
[TerminalTextEffects](https://github.com/ChrisBuilds/terminaltexteffects)
(Python). `jiwa` is intentionally narrower — reveal-shaped effects
only, Unix-pipe friendly, Rust single binary.
## License
MIT