DEV Community

Cover image for Building ratatop: a Rust System Monitor with ratatui
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

Building ratatop: a Rust System Monitor with ratatui

Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback.

For a long while now I've been trying to learn how to build TUI apps.

Last month I made peektea, a keyboard-driven terminal file browser. The whole journey is written up here: the peektea series.

GitHub logo lovestaco / peektea

Peek First. Open Later. A TUI file browser built with bubble tea

A minimal terminal file browser built with Bubble Tea.

Peek through your filesystem with arrow keys (or vim keys), then pour each file straight into the app you've configured for it.

Demo

A quick peek before you steep Click below to watch the demo on YouTube (7m), or scroll down to see gif (10s) peektea demo demo in gif version demo

Install

One-liner:

curl -fsSL https://raw.githubusercontent.com/lovestaco/peektea/master/scripts/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

Download a binary (no Go required) — grab the latest release for your platform from the releases page:

Platform File
Linux x86-64 peektea_*_linux_amd64.tar.gz
Linux arm64 peektea_*_linux_arm64.tar.gz
macOS x86-64 peektea_*_darwin_amd64.tar.gz
macOS Apple Silicon peektea_*_darwin_arm64.tar.gz

Extract and put the peektea binary anywhere on your $PATH.

Install with Go:

go install github.com/lovestaco/peektea@latest
Enter fullscreen mode Exit fullscreen mode

Build from source:

git clone https://github.com/lovestaco/peektea
cd peektea
make install
Enter fullscreen mode Exit fullscreen mode

make install puts the binary in ~/go/bin and figures out $PATH for you:

  1. Already reachable — done, nothing to do.
  2. ~/.local/bin is on…

That one was Bubble Tea with Go.

So I got curious about what the other side looks like.

What can Rust do here?

Which is how I landed on ratatui.

I'm connected to Orhun Parmaksız on LinkedIn, I keep seeing his posts about it on my feed, and eventually curiosity won.

So here we are.

I built a basic Linux visual monitor to learn the library:

ratatop

For the next few days I'll be poking at this and writing down whatever I learn. This is post one.

So what is ratatui?

ratatui is a Rust library for building terminal user interfaces.

It grew out of tui-rs, which went unmaintained back in 2023, and a group of folks picked it up and ran with it.

Orhun is one of the maintainers.

The name is a Ratatouille joke, and honestly the film's whole "anyone can cook" thing turned out to be the right energy for a first project.

You do not need to be a Rust wizard to get something on screen.

One thing to get straight early: ratatui is a library, not a framework.

Bubble Tea hands you an architecture.

You fill in Model, Update, View, and it runs the show.

ratatui hands you a drawing surface and says good luck.

That difference is the whole post, really.

The thing that broke my brain first

Bubble Tea is built on the Elm Architecture.

State goes in, messages come back, you return a new model, the framework re-renders.

It is retained and it is opinionated.

ratatui is immediate mode.

There is no widget tree living in memory between frames.

There is no View() that the framework calls for you.

Every single frame, you build the widgets from scratch, draw them, and throw them away.

Coming from Bubble Tea this felt wrong for about a day.

Where does the state live? Wherever you want. Who calls the render? You do. When do widgets get cleaned up? They were never alive.

Then it clicked, and it felt great.

No lifecycle, no diffing your own state, no "why did this component not re-render".

You want something on screen, you draw it.

Every frame.

The catch is that "you own the loop" also means you own everything else.
Quitting, resizing, timing, keyboard input, all of it is yours now.

The entire app is one loop

Here is genuinely the whole shape of a ratatui program:

fn main() -> std::io::Result<()> {
    let mut terminal = ratatui::init();   // alt screen + raw mode
    let result = run(&mut terminal);
    ratatui::restore();                   // put the terminal back
    result
}

fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
    loop {
        terminal.draw(|frame| {
            let hello = Paragraph::new("hello from the kitchen");
            frame.render_widget(hello, frame.area());
        })?;

        if event::poll(Duration::from_millis(100))? {
            if let Some(key) = event::read()?.as_key_press_event() {
                if key.code == KeyCode::Char('q') {
                    return Ok(());
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

ratatui::init() is the modern convenience wrapper.

It flips on raw mode and switches to the alternate screen buffer, the same trick vim and htop use so your shell history stays clean when you quit.

In peektea that was tea.WithAltScreen().

Same idea, different kitchen.

ratatui::restore() puts everything back.

Worth wiring this into a panic hook too, because a Rust panic inside raw mode leaves your terminal in a genuinely cursed state.

Input is not ratatui's job at all.

That comes from crossterm, which ratatui re-exports as ratatui::crossterm so your versions cannot drift apart.

event::poll with a timeout is what keeps the loop from blocking, so you can redraw on a timer instead of only on keypress.

Everything is a Rect

The core layout idea is small: you have a Rect, and you cut it into smaller Rects.

let [header, body, footer] = Layout::vertical([
    Constraint::Length(1),   // exactly one row
    Constraint::Min(0),      // whatever is left over
    Constraint::Length(1),   // exactly one row
])
.areas(area);
Enter fullscreen mode Exit fullscreen mode

That .areas() returning a fixed-size array is lovely.

The compiler checks that you destructured exactly three areas from three constraints.

Get it wrong and it does not build.

The constraint types cover most of what you want:

  • Length(n) for exact cells
  • Min(n) and Max(n) for flexible with a bound
  • Percentage(n) and Ratio(a, b) for proportional
  • Fill(weight) for splitting leftovers by weight

There is also .spacing(n) to put gaps between chunks, and .flex(Flex::Center) to control where slack goes when your constraints do not fill the space.

I used Flex::Center to centre a box, then swapped it to Flex::Start to pin it to the top of the terminal.

One enum, done.

Nesting is just calling Layout again on one of the pieces you got back.

That is the whole layout system.

Widgets are cheap, disposable, and consumed

A widget in ratatui is a plain value that gets eaten when you draw it.

The trait is literally:

pub trait Widget {
    fn render(self, area: Rect, buf: &mut Buffer);
}
Enter fullscreen mode Exit fullscreen mode

Note self, not &self. It is consumed.

You are not holding onto it.

Stock widgets get you surprisingly far.

Block is the bordered container, and it composes nicely:

let block = Block::default()
    .borders(Borders::ALL)
    .border_type(BorderType::Rounded)     // ╭─╮ instead of ┌─┐
    .title("CPU")
    .title_bottom(Line::from("q to quit").right_aligned());

let inner = block.inner(area);   // the area inside the borders
block.render(area, buf);
Enter fullscreen mode Exit fullscreen mode

block.inner(area) is the one to remember.

It gives you the space inside the border so your content does not get eaten by the frame.

For text, the hierarchy is Span (styled string) inside Line (a row) inside Text (many rows):

Line::from(vec![
    Span::styled("C", Style::default().fg(Color::Red)),
    Span::styled("PU", Style::default().fg(Color::White)),
])
Enter fullscreen mode Exit fullscreen mode

There is also a Stylize trait that makes this much less shouty, giving you "CPU".red().bold() directly on string literals.

Very nice for quick work.

When the stock widgets are not enough

This is where it got fun.

The look I was going after uses braille characters to fit more resolution into a terminal cell.

Each braille glyph is a 2x4 grid of dots, so one character cell holds 8 addressable points instead of 1.

No stock widget does that. So you write one. You implement Widget, you get a Buffer, and you set cells directly.

impl Widget for MyGraph<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let cell = &mut buf[(x, y)];
        cell.set_char('⣿');
        cell.set_style(Style::default().fg(Color::Green));
    }
}
Enter fullscreen mode Exit fullscreen mode

Buffer is a flat grid of Cells, where each cell holds a symbol plus its style. That is the entire drawing surface.

Once you realise a custom widget is just "write characters into a grid", the intimidation factor drops off a cliff.

Then I spent an embarrassing amount of time on the fact that braille dot numbering is not sequential.

The bits go 1, 2, 3 down the left column, 4, 5, 6 down the right, and then 7 and 8 got bolted on later as a fourth row.

So the bitmask is 0x01, 0x02, 0x04, 0x40 on the left and 0x08, 0x10, 0x20, 0x80 on the right.

That 0x40 sitting where 0x08 should be is a 19th century design decision reaching out to ruin your afternoon.

Worth it though.

The payoff is graphs that look like graphs instead of blocks.

The one mistake worth confessing

While drawing per-core graphs, I decided that any non-zero reading should light at least one dot.

My reasoning: an idle core showing literally nothing looks broken.

Reasonable. Also completely wrong.

The aggregate CPU line is never zero.

So every single column got exactly one dot, and my beautiful history graph rendered as a perfectly flat line, all the way across, forever.

I had built a very expensive way to draw a dash.

The fix was to delete my clever bit and let low values round down to blank.

Those gaps are the entire texture.

Letting values fall to nothing is what makes it look alive.

How a frame actually reaches your terminal

Last piece, because it explains a debugging trap I fell into.

ratatui keeps two buffers and only emits the cells that actually changed.

That is why redrawing everything every frame is not wasteful.

The trap: I tried to test a feature by piping the app's output and grepping for a string I expected on screen.

Found nothing, assumed the feature was broken. It was not.

Because of the diffing, a changed value never appears as a contiguous string in the output stream. It arrives as a few individual cells with cursor moves between them.

Lesson: test your state and your widgets, not the escape codes.

What I'd cook next

  • Loading theme files so colours are not hardcoded
  • More collectors, because right now it only knows about the CPU
  • Actually reading the ratatui tutorials properly instead of learning by flailing

If you've been on the fence about TUIs, ratatui is a genuinely nice place to start.

The API is small, the docs are good, and the immediate mode model means there is very little machinery hiding from you.

Code is here if you want a look: lovestaco/ratatop

GitHub logo lovestaco / ratatop

Keep an eye on what's cooking in your system

ratatop


A reimplementation of btop in Rust, built on ratatui.

The goal is a resource monitor that feels like btop: the four-box layout, braille graphs with gradient fills, mouse-driven navigation, and a themable process manager, but written as a modern Rust TUI.

Status

The CPU box works. It samples real data and renders btop's layout:

ratatop running

Per-core braille history graphs, a gradient load bar with sub-cell resolution frequency, package temperature, battery, and load averages.

Everything else, the memory, network, and process boxes, is still to come.

Design notes

  • Data source: /proc and /sys on Linux first, read directly rather than shelling out. btop's src/linux/btop_collect.cpp is the reference for what to read and how often; the BSD/macOS collectors are out of scope for v1.
  • Graphs are the hard part. btop's look comes from braille sub-cell plotting with a color gradient applied along the value axis, not from a stock chart widget. Expect…

More soon. Anyone can cook.


AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

GitHub logo HexmosTech / git-lrc

Free, Micro AI Code Reviews That Run on Git Commit




GenAI today is a race car without brakes. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents silently break things: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.

git-lrc is your braking system. It hooks into git commit and runs an AI review on every diff before it lands. 60-second setup. Completely free.

In short, git-lrc helps Prevent Outages, Breaches, and Technical Debt Before They Happen

At a glance: 10 risk categories · 100+ failure patterns tracked · every commit…


AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

GitHub logo HexmosTech / git-lrc

Free, Micro AI Code Reviews That Run on Git Commit




GenAI today is a race car without brakes. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents silently break things: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.

git-lrc is your braking system. It hooks into git commit and runs an AI review on every diff before it lands. 60-second setup. Completely free.

In short, git-lrc helps Prevent Outages, Breaches, and Technical Debt Before They Happen

At a glance: 10 risk categories · 100+ failure patterns tracked · every commit…

Top comments (0)