# Building ratatop: a Rust System Monitor with ratatui

> Source: <https://dev.to/lovestaco/building-ratatop-a-rust-system-monitor-with-ratatui-50n>
> Published: 2026-07-28 12:24:46+00:00

*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](https://github.com/charmbracelet/bubbletea).

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
](https://www.youtube.com/watch?v=yDNN5x9Y9Ok)

**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](https://github.com/lovestaco/peektea/releases/latest):

| 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](https://github.com/charmbracelet/bubbletea) 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](https://ratatui.rs).

I'm connected to [Orhun Parmaksız](https://github.com/orhun) 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](https://en.wikipedia.org/wiki/Ratatouille_(film)) 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](https://guide.elm-lang.org/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:

``` php
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](https://github.com/crossterm-rs/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 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 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:

``` js
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](https://en.wikipedia.org/wiki/Braille_Patterns) 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](https://github.com/aristocratos/btop) in Rust, built on
[ratatui](https://ratatui.rs).

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](https://github.com/HexmosTech/git-lrc/readme/README.da.md) | [🇪🇸 Español](https://github.com/HexmosTech/git-lrc/readme/README.es.md) | [🇮🇷 Farsi](https://github.com/HexmosTech/git-lrc/readme/README.fa.md) | [🇫🇮 Suomi](https://github.com/HexmosTech/git-lrc/readme/README.fi.md) | [🇯🇵 日本語](https://github.com/HexmosTech/git-lrc/readme/README.ja.md) | [🇳🇴 Norsk](https://github.com/HexmosTech/git-lrc/readme/README.nn.md) | [🇵🇹 Português](https://github.com/HexmosTech/git-lrc/readme/README.pt.md) | [🇷🇺 Русский](https://github.com/HexmosTech/git-lrc/readme/README.ru.md) | [🇦🇱 Shqip](https://github.com/HexmosTech/git-lrc/readme/README.sq.md) | [🇨🇳 中文](https://github.com/HexmosTech/git-lrc/readme/README.zh.md) | [🇮🇳 हिन्दी](https://github.com/HexmosTech/git-lrc/readme/README.hi.md) |

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](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · [100+ failure patterns tracked](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · 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](https://github.com/HexmosTech/git-lrc/readme/README.da.md) | [🇪🇸 Español](https://github.com/HexmosTech/git-lrc/readme/README.es.md) | [🇮🇷 Farsi](https://github.com/HexmosTech/git-lrc/readme/README.fa.md) | [🇫🇮 Suomi](https://github.com/HexmosTech/git-lrc/readme/README.fi.md) | [🇯🇵 日本語](https://github.com/HexmosTech/git-lrc/readme/README.ja.md) | [🇳🇴 Norsk](https://github.com/HexmosTech/git-lrc/readme/README.nn.md) | [🇵🇹 Português](https://github.com/HexmosTech/git-lrc/readme/README.pt.md) | [🇷🇺 Русский](https://github.com/HexmosTech/git-lrc/readme/README.ru.md) | [🇦🇱 Shqip](https://github.com/HexmosTech/git-lrc/readme/README.sq.md) | [🇨🇳 中文](https://github.com/HexmosTech/git-lrc/readme/README.zh.md) | [🇮🇳 हिन्दी](https://github.com/HexmosTech/git-lrc/readme/README.hi.md) |

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](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · [100+ failure patterns tracked](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · every commit…
