# 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](https://github.com/km-clay/whoa)</sub>
## Features
- **One trait** - implement `Animation` and you're done
- **Frame diffing** - only changed cells are redrawn each frame
- **ANSI parsing** - `FrameBuilder` parses 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** - `Grapheme` type handles multi-codepoint characters with stack allocation via `SmallVec`
## Quick start
Add cellophane to your project:
```sh
cargo add cellophane
```
Implement the `Animation` trait and let `Animator` handle the rest:
```rust
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
| `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 `Cell`s 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:
1. Enters the alternate screen, enables raw mode, hides the cursor
2. Each `tick()` polls for events, calls your `update()`, and renders the frame
3. Only cells that differ from the previous frame are written to the terminal
4. Resize events call your `resize()`, input events call your `on_event()`
5. Ctrl+C returns `Err(ErrorKind::Interrupted)` for clean shutdown
6. Terminal state is restored automatically on drop
## `ratatui` Integration
Enable with:
```sh
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:
```rust
fn main() -> std::io::Result<()> {
let mut anim = SomeAnimation::new();
anim.init(anim.initial_frame());
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](https://github.com/km-clay/whoa) - a terminal screensaver featuring EarthBound battle backgrounds, procedural simulations, cellular automata, and more