jiwa
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
[]
= "0.1"
Example: typewriter reveal
use ;
use ;
let start = now;
let reveal = start_at;
// Tick once per redraw — your event loop chooses the cadence.
let frame = reveal.snapshot;
for g in &frame
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 ;
let pulse = start;
let frame = pulse.snapshot;
// 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
|
# 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.
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 = Rgb;
// crossterm
let color = Rgb ;
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_coremodule (where they have been used in production since v0.6.0). - CLI binary —
jiwareads 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