Rio-vt and librio: Rio's terminal engine, now embeddable

vinhnx1 pts0 comments

rio-vt and librio: Rio's terminal engine, now embeddable | Rio Terminal

Skip to main content

Enjoying Rio? Sponsor the project to keep it going

Hey folks!

For a while now Rio's terminal core has been tangled up with its renderer, its config, and the app around it. That made it hard for anyone (including me) to reuse the parts that took years to get right: the VT state machine, the grid, scrollback, selection, search, and the image protocols.

With Rio 0.5 that changes. The engine is now split into clean, embeddable layers, and I want to walk you through them: rio-vt and librio .

Two layers​

The idea is simple. Most projects that want a terminal don't want a whole terminal app, they want the engine. So the engine now ships as two layers you can pick from:

rio-vt is a safe Rust crate. The VT state machine, ANSI/escape parser, grid with scrollback, selection, search, PTY driver, and the sixel / Kitty / iTerm2 image protocols. No rendering, no GPU, no font shaping. If you write Rust, you depend on this directly. Think of it as a modern alternative to alacritty_terminal.

librio is the same core behind a C ABI, in the spirit of libghostty. If you're writing Swift, C, Go, Python, or anything that speaks C, you link this: it ships as a prebuilt static library, no Rust toolchain required. All the unsafe lives here and rio-vt itself stays safe Rust.

Both are lean by default. Building rio-vt with no features pulls in no renderer, GPU, or font-shaping dependencies, it's just the terminal.

rio-vt in practice​

The core is driven the way a real terminal is: feed it bytes, then pull the grid state back out. Here's the whole loop, no PTY, no renderer, no threads:

use rio_vt::ansi::CursorShape;

use rio_vt::crosswords::grid::Dimensions;

use rio_vt::crosswords::pos::Column;

use rio_vt::crosswords::{Crosswords, CrosswordsSize};

use rio_vt::event::{VoidListener, WindowId};

use rio_vt::performer::handler::Processor;

// An 80x24 terminal with 10k lines of scrollback.

let size = CrosswordsSize::new(80, 24);

let cols = size.columns();

let mut term = Crosswords::new(

size, CursorShape::Block, VoidListener, WindowId::from(0), 0, 10_000,

);

// Feed raw PTY bytes: SGR red "hello", reset, then " world".

let mut parser = Processor::default();

parser.advance(&mut term, b"\x1b[31mhello\x1b[0m world");

// Pull the visible grid and read the first row.

let rows = term.visible_rows();

let line: String = (0..cols).map(|x| rows[0][Column(x)].c()).collect();

assert!(line.starts_with("hello world"));

Since rio-vt never draws anything, you decide what happens with the grid: pair it with any renderer, or none at all.

Selection and search come for free​

use rio_vt::crosswords::pos::{Column, Line, Pos, Side};

use rio_vt::selection::{Selection, SelectionType};

let mut selection = Selection::new(

SelectionType::Simple, Pos::new(Line(0), Column(0)), Side::Left,

);

selection.update(Pos::new(Line(0), Column(4)), Side::Right);

term.selection = Some(selection);

assert_eq!(term.selection_to_string().as_deref(), Some("hello")); // columns 0..=4

Images, too​

Sixel, Kitty, and iTerm2 image transmissions are decoded by the core and surfaced through an event, so a frontend records them the way it records anything else. Here's a Kitty graphics-protocol transmission captured headless:

impl EventListener for ImageSink {

fn event(&self) -> (OptionRioEvent>, bool) { (None, false) }

fn send_event(&self, event: RioEvent, _: WindowId) {

if let RioEvent::UpdateGraphics { queues, .. } = event {

for (id, g) in &queues.pending_images {

// id, g.width, g.height, g.pixels (RGBA) ...

// f=32 RGBA, 2x2 px, a=T transmit+display, base64 pixels:

parser.advance(&mut term, b"\x1b_Gf=32,s=2,v=2,a=T;/wAA//8AAP//AAD//wAA/w==\x1b\\");

// -> kitty image: id=..., 2x2px, 16 rgba bytes

librio: the same core, from any language​

librio wraps that engine in a small C ABI: create an engine, create a surface (which spawns a PTY under the hood), write to it, and pull a render state that tells you which rows changed and what each cell holds.

#include "librio.h"

rio_engine_t *engine = rio_engine_new(&config);

rio_surface_t *surface = rio_surface_new(engine, &desc);

// Send input to the shell.

rio_surface_text(surface, "ls -la\n", 7);

// Pull only the rows that changed and draw them.

rio_render_state_t *state = rio_render_state_new(surface);

rio_render_state_update(state);

for (uint16_t line = 0; line rio_render_state_lines(state); line++) {

if (!rio_render_state_row_dirty(state, line)) continue;

for (uint16_t col = 0; col rio_render_state_columns(state); col++) {

rio_cell_s cell = rio_render_state_cell(state, line, col);

// hand `cell` to your own renderer...

rio_render_state_reset_dirty(state);

That's the exact surface a native Swift or C frontend consumes, cell for cell. The dirty-row render state is what makes CPU rendering practical: a consumer only repaints the cells the terminal says changed, without reimplementing a single...

state terminal selection line engine rio_vt

Related Articles