Skip to content
Offscreen rendering

Offscreen rendering

Not every frame needs a live terminal. A Screen<W> is a desired cell grid, a diff renderer, and a writer. The writer can be any std::io::Write: a Vec<u8> for tests, a File for transcripts, a socket, or a terminal handle. Screen::new(writer, size) is infallible and touches no terminal state, so you can render offscreen without a Program at all.

Rendering into bytes

Build a screen around a Vec<u8>, draw the frame, and call render. The escape bytes land in the vector because that vector is the writer.

use uncurses::screen::Screen;
use uncurses::style::Style;
use uncurses::text::TextSurface;

fn main() -> std::io::Result<()> {
    let mut screen = Screen::new(Vec::new(), (40, 3));
    screen.set_str((0, 0), "rendered with no terminal", Style::new());
    screen.render()?;

    let bytes = screen.into_writer();
    assert!(!bytes.is_empty());
    Ok(())
}

No input is opened, raw mode is not enabled, and no query is sent. The only I/O is the render call writing frame bytes to the writer you supplied.

Writing somewhere else

The writer does not have to be memory. A File works the same way, which is useful for transcripts or fixtures that you replay later.

use std::fs::File;

use uncurses::screen::Screen;
use uncurses::style::Style;
use uncurses::text::TextSurface;

fn main() -> std::io::Result<()> {
    let file = File::create("target/uncurses-frame.ansi")?;
    let mut screen = Screen::new(file, (40, 3));
    screen.set_str((0, 0), "saved as escape bytes", Style::new());
    screen.render()
}

Use the same pattern for any Write. If you later write those bytes to a real terminal, the frame paints inline at that terminal’s current cursor.

Style-free snapshots

For golden tests, set the screen’s color profile before rendering. The setter is plain and infallible: it only changes how styled cells will be encoded by later frames. Profile::Disabled drops styling escapes, leaving only the frame bytes needed to place the cells.

use uncurses::color::{Color, Profile};
use uncurses::screen::Screen;
use uncurses::style::Style;
use uncurses::text::TextSurface;

fn main() -> std::io::Result<()> {
    let mut screen = Screen::new(Vec::new(), (40, 3));
    screen.set_color_profile(Profile::Disabled);
    screen.set_str((0, 0), "plain snapshot", Style::new().fg(Color::BrightCyan));
    screen.render()?;

    let bytes = screen.into_writer();
    let text = String::from_utf8(bytes).expect("renderer writes utf-8 escapes");
    assert!(text.contains("plain snapshot"));
    Ok(())
}

Because the same drawing API works with Vec<u8>, File, and terminal output, you compose a frame once and choose only the writer and color profile for the place it is going.

Reading cells back

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

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

fn main() {
    let mut screen = Screen::new(Vec::new(), (40, 3));
    screen.set_str((0, 0), "rendered with no terminal", Style::new());

    let mut lines = Vec::new();
    for y in 0..screen.height() {
        let mut line = String::new();
        for x in 0..screen.width() {
            line.push_str(screen.cell(Position::new(x, y)).map_or(" ", Cell::content));
        }
        lines.push(line.trim_end().to_string());
    }

    assert_eq!(lines[0], "rendered with no terminal");
}

Nothing here is Screen-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.