cd /news/developer-tools/building-ratatop-a-rust-system-monit… · home topics developer-tools article
[ARTICLE · art-76898] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Building ratatop: a Rust System Monitor with ratatui

Developer Maneshwar built ratatop, a Linux system monitor using the Rust ratatui library, as a learning project to explore immediate-mode terminal user interfaces. The project contrasts with the developer's previous Bubble Tea-based TUI app peektea, highlighting ratatui's library approach versus Bubble Tea's framework architecture.

read11 min views1 publishedJul 28, 2026

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**.

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.

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

One-liner:

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

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

Build from source:

git clone https://github.com/lovestaco/peektea
cd peektea
make install

make install

puts the binary in ~/go/bin

and figures out $PATH

for you:

~/.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:

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

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.

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.

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(());
                }
            }
        }
    }
}

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.

The core layout idea is small: you have a Rect

, and you cut it into smaller Rect

s.

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);

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 cellsMin(n)

and Max(n)

for flexible with a boundPercentage(n)

and Ratio(a, b)

for proportionalFill(weight)

for splitting leftovers by weightThere 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.

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);
}

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);

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)),
])

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.

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));
    }
}

Buffer

is a flat grid of Cell

s, 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.

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.

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.

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

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.

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

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.

/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.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:

| 🇩🇰 Dansk | 🇪🇸 Español | 🇮🇷 Farsi | 🇫🇮 Suomi | 🇯🇵 日本語 | 🇳🇴 Norsk | 🇵🇹 Português | 🇷🇺 Русский | 🇦🇱 Shqip | 🇨🇳 中文 | 🇮🇳 हिन्दी |

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 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:

| 🇩🇰 Dansk | 🇪🇸 Español | 🇮🇷 Farsi | 🇫🇮 Suomi | 🇯🇵 日本語 | 🇳🇴 Norsk | 🇵🇹 Português | 🇷🇺 Русский | 🇦🇱 Shqip | 🇨🇳 中文 | 🇮🇳 हिन्दी |

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 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…

── more in #developer-tools 4 stories · sorted by recency
── more on @maneshwar 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-ratatop-a-r…] indexed:0 read:11min 2026-07-28 ·