Cellophane
A terminal animation framework for Rust
Install / Use
/learn @km-clay/CellophaneREADME
Cellophane
A terminal animation framework for Rust. Implement one trait, get a complete rendering pipeline with frame diffing, resize handling, and input forwarding.
<sub>Animation provided by whoa</sub>
Features
- One trait - implement
Animationand you're done - Frame diffing - only changed cells are redrawn each frame
- ANSI parsing -
FrameBuilderparses ansi-styled text (including 24-bit color) into a cell grid via VTE - Resize handling - terminal resize events are detected and forwarded automatically
- Input forwarding - key and mouse events are passed to your animation for interactive use
- Terminal lifecycle - alternate screen, raw mode, cursor visibility, and cleanup on drop
- Unicode support -
Graphemetype handles multi-codepoint characters with stack allocation viaSmallVec
Quick start
Add cellophane to your project:
cargo add cellophane
Implement the Animation trait and let Animator handle the rest:
use std::time::Duration;
use cellophane::{Animation, Animator, Frame, Cell};
use cellophane::crossterm::style::Color;
struct Rainbow {
tick: usize,
rows: usize,
cols: usize,
}
impl Animation for Rainbow {
fn init(&mut self, initial: Frame) {
let (rows, cols) = initial.dims().unwrap_or((0, 0));
self.rows = rows;
self.cols = cols;
}
fn update(&mut self, _dt: Duration) -> Frame {
let mut frame = Frame::with_capacity(self.cols, self.rows);
for row in 0..self.rows {
for col in 0..self.cols {
let hue = ((col + row + self.tick) % 256) as u8;
if let Some(cell) = frame.get_cell_mut(row, col) {
*cell = Cell::default()
.with_bg(Color::Rgb { r: hue, g: 100, b: 255 - hue });
}
}
}
self.tick += 1;
frame
}
fn is_done(&self) -> bool { false }
fn resize(&mut self, w: usize, h: usize) {
self.cols = w;
self.rows = h;
}
}
fn main() -> std::io::Result<()> {
let anim = Box::new(Rainbow { tick: 0, rows: 0, cols: 0 });
let mut animator = Animator::enter_with(anim)?;
loop {
match animator.tick() {
Ok(true) => continue,
Ok(false) => break,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => break,
Err(e) => return Err(e),
}
}
Ok(())
}
Core types
| Type | Description |
|------|-------------|
| Animation | The trait you implement to define an animation |
| Animator | Drives your animation with a frame-rate-limited render loop |
| Frame | A grid of styled Cells representing one frame of output |
| FrameBuilder | Parses ANSI-escaped text into a Frame |
| Cell | A single terminal cell with character, colors, and attributes |
| CellFlags | Bitflags for text attributes (bold, italic, underline, etc.) |
| Grapheme | A unicode grapheme cluster backed by SmallVec<[char; 4]> |
How it works
The Animator manages the full terminal lifecycle:
- Enters the alternate screen, enables raw mode, hides the cursor
- Each
tick()polls for events, calls yourupdate(), and renders the frame - Only cells that differ from the previous frame are written to the terminal
- Resize events call your
resize(), input events call youron_event() - Ctrl+C returns
Err(ErrorKind::Interrupted)for clean shutdown - Terminal state is restored automatically on drop
ratatui Integration
Enable with:
cargo add cellophane --features=ratatui
This feature flag exposes the AnimationWidget struct, which allows Frame to be usable in the ratatui rendering pipeline.
AnimationWidget implements the Widget trait so it can be composed with other ratatui widgets as you would expect.
Here's an example using the Block widget:
fn main() -> std::io::Result<()> {
let mut anim = SomeAnimation::new();
anim.init();
ratatui::run(|terminal| {
loop {
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(f.area());
let block = Block::default().title("Animation").borders(ratatui::widgets::Borders::ALL);
let block_inner = block.inner(chunks[0]);
// resize the animation to match block_inner
anim.resize(block_inner.width as usize, block_inner.height as usize);
let anim_frame = anim.update(); // get the frame
// render the block widget, and the animation frame inside of it
f.render_widget(block, chunks[0]);
f.render_widget(AnimationWidget::new(&anim_frame), block_inner);
})?;
if event::poll(Duration::from_millis(16))? {
if event::read()?.is_key_press() {
break Ok(());
}
}
}
})
}
Built with cellophane
- whoa - a terminal screensaver featuring EarthBound battle backgrounds, procedural simulations, cellular automata, and more
