Skip to content
Offscreen rendering

Offscreen rendering

Not every frame needs a live terminal. A TextBuffer is a width-aware cell grid with no terminal behind it: you paint a whole frame into memory, then serialize it to escape bytes you can write anywhere. It is the tool for snapshot tests, transcript recorders, and shipping frames over a socket.

Painting into memory

TextBuffer::new(width, height) gives you a grid that supports the same set_str, set_cell, and fill_rect calls as a Screen, but owns no input or output and never touches raw mode.

use uncurses::buffer::TextBuffer;
use uncurses::style::Style;
use uncurses::text::TextSurface;

let mut frame = TextBuffer::new(40, 3);
frame.set_str((0, 0), "rendered with no terminal", Style::new());

No terminal state is tracked here. Every serialization produces a complete, standalone frame, not a delta against a previous one.

Serializing to bytes

The Encode trait turns the grid into escape bytes. encode writes into any Write; display gives you a Display value when a string is more convenient.

use uncurses::buffer::TextBuffer;
use uncurses::style::Style;
use uncurses::text::{Encode, TextSurface};

let mut frame = TextBuffer::new(40, 3);
frame.set_str((0, 0), "rendered with no terminal", Style::new());

let mut bytes = Vec::new();
frame.encode(&mut bytes).unwrap(); // into a Vec<u8>

let string = frame.display().to_string(); // or straight to a String

Write those bytes to stdout and the frame paints inline at the current cursor, carrying its own SGR and line breaks. Send them over a socket and the other end renders the same frame.

Plain text and snapshot tests

To pin output for a golden test, you usually want plain text or escapes without color. Serialize through a color Profile with the *_with variants: Profile::Disabled drops all styling and gives you plain text, while Profile::Ascii keeps attributes but strips color.

use uncurses::buffer::TextBuffer;
use uncurses::color::Profile;
use uncurses::style::Style;
use uncurses::text::{Encode, TextSurface};

let mut frame = TextBuffer::new(40, 3);
frame.set_str((0, 0), "rendered with no terminal", Style::new());

let plain = frame.display_with(Profile::Disabled).to_string();
assert_eq!(plain.trim_end(), "rendered with no terminal");

Because the same TextBuffer can emit full-color escapes for display and plain text for assertions, you compose a frame once and check it however the test needs.

Reading cells back

The grid is also queryable. Walk it cell by cell to extract the text directly, which is handy for transcripts or diffing two frames yourself.

use uncurses::buffer::{Surface, TextBuffer};
use uncurses::cell::Cell;
use uncurses::layout::Position;
use uncurses::style::Style;
use uncurses::text::TextSurface;

let mut frame = TextBuffer::new(40, 3);
frame.set_str((0, 0), "rendered with no terminal", Style::new());

for y in 0..frame.height() {
    let mut line = String::new();
    for x in 0..frame.width() {
        line.push_str(frame.cell(Position::new(x, y)).map_or(" ", Cell::content));
    }
    println!("{}", line.trim_end());
}

Nothing here is TextBuffer-specific. cell, width, and height come from the Surface and Bounded traits, so the same walk reads back any surface: a Buffer, Window, View, or live Screen. Anywhere you can paint, you can also query.

See the offscreen example, which composes a bordered, colored card entirely in memory and then replays the exact bytes on your terminal.