Rust · Ratatui · terminal interfaces

A data grid that feels at home in your terminal.

Define typed columns, pass records, and forward input. Ratagrid gives your Ratatui app sorting, selection, search, resizing, and pagination.

Mouse + keyboardStable record IDsUnicode clippingMIT licensed
Ratagrid playground showing synthetic jobs with typed columns and semantic colors
Synthetic fixtures. Rendered from real playground terminal I/O.

22-second tour

Sort. Resize. Browse.

This is an actual terminal-output recording rendered into video, not a native iTerm screen recording. It contains only synthetic records. No desktop or account information was captured.

Walkthrough transcript and controls

The tour selects a row, sorts Latency, resizes the ID column, browses a million resident rows, switches to Unicode and light theme, and pages a simulated 100-million-record source. The virtual source loads only one page; it does not allocate 100 million records.

Try it locally

Rust 1.99.0 and a terminal with mouse reporting are required. Ratagrid 0.3.0 is an independent widget for Ratatui 0.30.

git clone https://github.com/kahwee/ratagrid.git
cd ratagrid
cargo run --release --locked --example playground
ControlAction
1–7Choose a scenario
Tab, EnterFocus and sort a header
Arrows, click a cellMove the row and cell cursor
Drag a separator, + / −Resize a column
/, Enter, EscapeSearch, apply, cancel
T · B · ATheme · boost values · toggle motion
[ / ] · P · QPrevious/next page · page size · quit

Small details, large views.

The ID column being resized using its separator handle
Column resizing preserves complete graphemes and sort indicators.
Unicode values including CJK, accents, and emoji in the grid
Wide characters, combining marks, and emoji remain whole.
One million synthetic resident rows with the final record selected
Owned sorting processes all resident rows synchronously.
A simulated 100-million-record source with one page resident
External paging keeps one returned page in memory.

A complete runnable example

Use it with Ratatui.

Ratagrid is a widget inside your existing event loop. Your application owns terminal setup, focus, source I/O, clipboard writes, and cleanup. Render grid.widget(), then forward events with grid.handle_event(&event). Render again before the next mouse event so hit-testing uses the latest layout.

# In this checkout: exact source is examples/positions.rs
cargo run --locked --example positions

Version 0.3 adds bar-chart columns and faster rendering and sorted updates. See the cross-platform measurements. Version 0.2 adds optional full-value panels and native cursor/keyset pagination. See the release and migration notes; Action is now Clone rather than Copy.

The synthetic positions example keeps amounts in integer cents and changes in basis points. Comparators use numeric values; formatting and colors stay in the application. It covers negative amounts, integer limits, selection, and sorting. No real accounts or market data are included.

For a separate application, add these dependencies and save the example below as src/main.rs:

[package]
name = "positions-demo"
version = "0.3.0"
edition = "2024"

[dependencies]
ratagrid = "0.3"
ratatui = "0.30"
crossterm = "0.29"

Install Ratagrid from crates.io. Use =0.3.0 for this exact release and retain Cargo.lock. This site follows main; the versioned API reference follows the published crate.

Show full Rust source
//! Synthetic positions: integer minor units and basis points, never real accounts.
use crossterm::{
    event::{
        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
    },
    execute,
};
use ratagrid::{Column, Grid};
use ratatui::{
    layout::{Constraint, Layout},
    style::{Color, Style},
    widgets::Paragraph,
};
use std::{io, time::Duration};

struct Position {
    id: u64,
    symbol: &'static str,
    value_cents: i64,
    change_bps: i64,
}

// Keep calculations in integer units; formatting cannot change numeric sorting.
fn money(cents: i64) -> String {
    let magnitude = cents.unsigned_abs();
    format!(
        "{}${}.{:02}",
        if cents < 0 { "-" } else { "" },
        magnitude / 100,
        magnitude % 100
    )
}
fn percent(bps: i64) -> String {
    let magnitude = bps.unsigned_abs();
    format!(
        "{}{}.{:02}%",
        if bps < 0 { "-" } else { "+" },
        magnitude / 100,
        magnitude % 100
    )
}
fn positions() -> Grid<Position> {
    let columns = vec![
        Column::new("Symbol", 16, |p: &Position| p.symbol.into())
            .sortable(|a, b| a.symbol.cmp(b.symbol)),
        Column::new("Value (USD)", 24, |p: &Position| money(p.value_cents))
            .sortable(|a, b| a.value_cents.cmp(&b.value_cents)),
        Column::new("Change", 16, |p: &Position| percent(p.change_bps))
            .sortable(|a, b| a.change_bps.cmp(&b.change_bps))
            .cell_style(|p| {
                Style::default().fg(if p.change_bps < 0 {
                    Color::Red
                } else {
                    Color::Green
                })
            }),
    ];
    Grid::new(
        columns,
        vec![
            Position {
                id: 1,
                symbol: "EXAMPLE-A",
                value_cents: 123_456,
                change_bps: 125,
            },
            Position {
                id: 2,
                symbol: "EXAMPLE-B",
                value_cents: 9_999,
                change_bps: -75,
            },
            Position {
                id: 3,
                symbol: "EXAMPLE-C",
                value_cents: -250,
                change_bps: 0,
            },
        ],
    )
    .with_row_id(|p| p.id)
}

fn main() -> io::Result<()> {
    let mut grid = positions();
    let mut terminal = ratatui::init();
    let result = (|| {
        execute!(io::stdout(), EnableMouseCapture)?;
        loop {
            terminal.draw(|frame| {
                let areas = Layout::vertical([
                    Constraint::Length(2),
                    Constraint::Min(1),
                    Constraint::Length(2),
                ])
                .split(frame.area());
                frame.render_widget(
                    Paragraph::new("Synthetic positions / Ratagrid + Ratatui"),
                    areas[0],
                );
                frame.render_widget(grid.widget(), areas[1]);
                frame.render_widget(
                    Paragraph::new("Tab + Enter: sort · Arrows: select · /: search · q: quit"),
                    areas[2],
                );
            })?;
            if !event::poll(Duration::from_millis(100))? {
                continue;
            }
            let event = event::read()?;
            if matches!(&event, Event::Key(k) if k.kind != KeyEventKind::Release && k.code == KeyCode::Char('q') && !k.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) && !grid.is_searching())
            {
                break;
            }
            // Route input here before application shortcuts while search owns focus.
            grid.handle_event(&event);
        }
        Ok(())
    })();
    let cleanup = execute!(io::stdout(), DisableMouseCapture);
    ratatui::restore();
    result.and(cleanup)
}

Choose a runnable example · Download the tested source · Source integration guide · Search and selection guide

Open sourcing one useful piece at a time.

I'm gradually open sourcing reusable components from a few small closed-source projects. Those projects have a strong terminal-interface focus. Ratagrid is one piece that can stand on its own and help other Ratatui applications.

The grid stays general-purpose. Financial tables can supply their own exact numeric types, formatting, and source logic; the component does not own financial calculations, persistence, or trading decisions.

Owned sorts and filters are synchronous. Keyed bulk-mark restoration scans replacement rows per marked ID; large selections can make reloads expensive. Prefer external pagination when datasets or frequent reloads exceed an interactive application's budget.