Skip to content
The ratatui backend

The ratatui backend

If you already write your UI with ratatui widgets, you do not have to give that up to get uncurses underneath. The uncurses-ratatui crate is a ratatui Backend that wraps an uncurses Program: ratatui keeps writing widgets, the program owns input and terminal modes, and its Screen diffs the frames and writes only the necessary bytes.

Install

Add the backend alongside ratatui:

[dependencies]
uncurses-ratatui = { git = "https://github.com/aymanbagabas/uncurses" }
ratatui = "0.30"

The features mirror the core crate: unicode-rs (default), icu, and async.

Setup and teardown

try_init enters raw mode, switches to the alternate screen, and hands you a ready ratatui Terminal; restore restores the terminal state. One call each, bracketing your app.

use std::io;

fn main() -> io::Result<()> {
    let mut terminal = uncurses_ratatui::try_init()?;
    let result = run(&mut terminal);
    uncurses_ratatui::restore(&mut terminal);
    result
}

fn run(_: &mut uncurses_ratatui::DefaultTerminal) -> io::Result<()> {
    Ok(())
}

try_init returns an io::Result; if you would rather panic on failure, init gives you the terminal directly. There are *_with_options variants when you want to pass ratatui TerminalOptions and uncurses ProgramOptions. ProgramOptions is re-exported from uncurses_ratatui and defined in uncurses::program. Its fields are bracketed_paste, mouse, prefer_grapheme_clusters, prefer_in_band_resize, and prefer_synchronized_output. The three prefer_ ones are the only ones that depend on capability detection, so they do nothing unless you query and read the replies.

use std::io;

use ratatui::{TerminalOptions, Viewport};
use uncurses_ratatui::ProgramOptions;

fn main() -> io::Result<()> {
    let program_options = ProgramOptions {
        bracketed_paste: false,
        ..ProgramOptions::default()
    };

    let mut terminal = uncurses_ratatui::try_init_with_options(
        TerminalOptions {
            viewport: Viewport::Fullscreen,
        },
        program_options,
    )?;

    uncurses_ratatui::restore(&mut terminal);
    Ok(())
}

Drawing and input

terminal.draw is plain ratatui. Render any widget into the frame; uncurses turns the resulting buffer into the smallest possible update. Input comes from uncurses through the backend.

use uncurses::event::Event;
use uncurses_ratatui::DefaultTerminal;

fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
    loop {
        terminal.draw(|frame| {
            frame.render_widget("Hello from ratatui, via uncurses", frame.area());
        })?;

        let backend = terminal.backend_mut();
        if !backend.poll_event(None)? {
            continue;
        }
        let Some(ev) = backend.try_read_event()? else {
            continue;
        };
        if let Event::KeyPress(_) = ev {
            break;
        }
    }
    Ok(())
}

backend_mut exposes the same read_event, poll_event, and try_read_event you use on a bare Program, producing uncurses Event values. It also exposes the wrapped program as program() and program_mut(), so renderer access is backend.program_mut().screen_mut(). Your render layer is ratatui and your input layer is uncurses, each doing what it is best at. Features covered in the other guides, including mouse input, paste, async events, and terminal queries, work through the backend.

init and try_init do not probe the terminal, and no replies are drained. Querying is opt-in with backend.program_mut().query_capabilities(&[])?. Synchronous reads observe replies automatically. If you read through event_stream(), pass each event to backend.observe_event(&ev)?.

Async input

With the async feature, the backend exposes its own event_stream() for a tokio::select! loop. Unlike the sync reads, it yields events without observing them, so pair it with observe_event to keep capability tracking alive:

use std::time::Duration;

use tokio_stream::StreamExt;

let mut events = terminal.backend_mut().event_stream();
let mut tick = tokio::time::interval(Duration::from_millis(16));

loop {
    tokio::select! {
        maybe = events.next() => {
            let Some(ev) = maybe else { break };
            let ev = ev?;
            terminal.backend_mut().observe_event(&ev)?;
            // handle input
        }
        _ = tick.tick() => {
            terminal.draw(|frame| {
                frame.render_widget("async ratatui", frame.area());
            })?;
        }
    }
}

The stream shares the program’s decoder by handle, so you can hold it and still call mutable backend methods in the same loop. See the async events guide for the full pattern.

See the ratatui_minimal example for the smallest complete program, and browse the other ratatui_* examples for more, such as ratatui_popup, ratatui_inline, and ratatui_user_input.