Skip to content
Select theme

Charts and sparklines

Step 7 of 8. Step 6 left a frame with the machine’s real process table, a selected row, sorting and a filter. This step adds the pane step 1 sketched: a bottom row that draws the selected process’s CPU history as a sparkline. Along the way it picks up two more things the track promised — the ? help overlay, and space to freeze the tail — and it fixes a filter that did not survive its own editor. Start from tag tui-ch06; the end state is tagged tui-ch07.

Every refresh now records one CPU sample per process. The window is a map from pid to a bounded deque, and the pid is the key for the reason step 6 stored the selection as one: the row order changes every second, and the history has to follow the process, not the row.

after 65 refreshes: 60 samples, oldest 5.0, newest 64.0
after a second process appears: 2 pids
after rlog exits: 1 pids
rlog still there? false

Sixty samples at one refresh per second is a minute of history: long enough to see a process start working, short enough that the pane is never stale. The second bound is the one that matters on a real machine — a process that exits leaves the map at the next refresh, so a machine that churns short-lived processes does not grow the history without end, one dead pid at a time. The samples themselves are cheap: about 700 live processes on this machine, 60 samples each, four bytes a sample, is 164 KiB.

Sparkline counts in u64 and scales every bar against the maximum in the window, so the series is built at draw time from the floats:

┌ cpu history · rlog (pid 1042) · 25.0% now · 6/60 samples ┐
│ ▂█▆▅│
└──────────────────────────────────────────────────────────┘

The two small samples draw as nothing. The widget scales each bar against the window’s maximum across eight steps — a one-row pane has eight steps of resolution, which is what the nine-level bar glyphs are — and a sample under an eighth of the maximum is a zero-height bar, a blank column. That is not a special case in the code; it is what the scaling does to a sample that small, and it is why the pane’s title carries the count.

layout returns six rects now. The history pane takes HISTORY_ROWS = 3 — the border pair and the single row the sparkline draws in — between the panes and the filter line:

use ratatui::layout::{Constraint, Layout, Rect};
/// The history pane: a border pair and the one row the sparkline draws in.
const HISTORY_ROWS: u16 = 3;
/// At or above this width the panes sit side by side; below it they stack.
const WIDE: u16 = 80;
fn layout(area: Rect) -> [Rect; 6] {
let [header, main, history, filter, status] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(HISTORY_ROWS),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area);
if area.width >= WIDE {
let [table, log] =
Layout::horizontal([Constraint::Percentage(38), Constraint::Fill(1)]).areas(main);
[header, table, log, history, filter, status]
} else {
let [table, log] =
Layout::vertical([Constraint::Length(11), Constraint::Fill(1)]).areas(main);
[header, table, log, history, filter, status]
}
}
fn main() {
for area in [
Rect::new(0, 0, 100, 30),
Rect::new(0, 0, 40, 6),
Rect::new(0, 0, 40, 5),
] {
let [header, table, log, history, filter, status] = layout(area);
println!(
"{}x{} -> header {:?} table {:?} log {:?} history {:?} filter {:?} status {:?}",
area.width, area.height, header, table, log, history, filter, status
);
}
}
100x30 -> header Rect { x: 0, y: 0, width: 100, height: 1 } table Rect { x: 0, y: 1, width: 38, height: 24 } log Rect { x: 38, y: 1, width: 62, height: 24 } history Rect { x: 0, y: 25, width: 100, height: 3 } filter Rect { x: 0, y: 28, width: 100, height: 1 } status Rect { x: 0, y: 29, width: 100, height: 1 }
40x6 -> header Rect { x: 0, y: 0, width: 40, height: 1 } table Rect { x: 0, y: 1, width: 40, height: 0 } log Rect { x: 0, y: 1, width: 40, height: 0 } history Rect { x: 0, y: 1, width: 40, height: 3 } filter Rect { x: 0, y: 4, width: 40, height: 1 } status Rect { x: 0, y: 5, width: 40, height: 1 }
40x5 -> header Rect { x: 0, y: 0, width: 40, height: 1 } table Rect { x: 0, y: 1, width: 40, height: 0 } log Rect { x: 0, y: 1, width: 40, height: 0 } history Rect { x: 0, y: 1, width: 40, height: 2 } filter Rect { x: 0, y: 3, width: 40, height: 1 } status Rect { x: 0, y: 4, width: 40, height: 1 }

At 100x30 the two panes are 24 rows tall, three fewer than step 6’s 27, and the table shows 21 of them. At six rows the fixed lines exactly fill the frame and the panes get nothing — a zero-height rect draws nothing rather than complaining. One row fewer and the solver takes the row out of the history pane, which drops to two, rather than out of the header, the filter line or the status line: the three lines a reader needs stay on screen however small the window gets.

The pane’s title carries what the bars cannot: the subject’s name and pid, its latest sample, and how much of the window is filled. That last part earns its place because an all-zero history draws a blank line — zero is the bottom of the scale, and a flat zero looks exactly like no data at all without the count:

rlog · 699 processes · 15.6/64.0 GB resident
┌ processes [26/699] · cpu desc ─────┐┌ log [1/2] ─────────────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│ 836 axassetsd 0.0 0.0││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│ 767 imagent 0.0 0.0││ │
│ 854 amsaccountsd 0.0 0.0││ │
│ 965 WorkBuddy Helper 0.0 0.1││ │
│ 758 sharingd 0.0 0.2││ │
│ 697 trustd 0.0 0.0││ │
│ 723 WallpaperImageEx 0.0 1.2││ │
│ 755 replicatord 0.0 0.0││ │
│ 1417 visualintelligen 0.0 0.2││ │
│ 2655 sandbox-cli 0.0 0.0││ │
│ 734 NotificationCent 0.0 0.1││ │
│ 852 remindd 0.0 0.1││ │
│ 738 replayd 0.0 0.0││ │
│ 8568 VTDecoderXPCServ 0.0 0.1││ │
│ 810 mediaanalysisd 0.0 0.9││ │
│ 741 corespotlightd 0.0 0.4││ │
│ 630 php-fpm 0.0 0.0││ │
│ 899 php-fpm 0.0 0.0││ │
│ 900 php-fpm 0.0 0.0││ │
│ 901 php-fpm 0.0 0.0││ │
│▸1 launchd 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
┌ cpu history · launchd (pid 1) · 0.0% now · 4/60 samples ─────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
press / to filter
draw 3 · log 2@128B · key (none yet) · mouse (none yet) · 100x30 · theme slate

The subject is launchd, and the reason is in the frame. The baseline refresh reads 0.0% for every process — sysinfo computes CPU usage as a delta between two refreshes, and the first look has nothing to subtract — so the cpu desc sort ties everywhere and breaks the tie by pid, which puts pid 1 on top, and the selection takes the top row when it has nothing else. A second later the real numbers arrive: twenty-five processes turn out to have a sliver of CPU and launchd has none at all, so it sinks from the top of the order to row 26 of 699 — the first of the processes that measured exactly zero. The selection is a pid rather than a row, so it sinks with it, and the table scrolls to keep it in sight. An idle process has an idle history, and the pane says so in words rather than drawing an empty box and hoping.

A sparkline of zeros proves the plumbing and nothing else, so the capture below was taken with a yes loop burning a core, filtered to with / yes Enter and selected with j — the filter narrows the table to 28 rows, and the top of a CPU-sorted view is the busy one:

rlog · 699 processes · 15.6/64.0 GB resident
┌ processes [1/28] · cpu desc ───────┐┌ log empty ─────────────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││ │
│▸74232 yes 100.1 0.0││ │
│ 754 identityservices 2.4 0.1││ │
│ 358 systemstats 0.0 0.0││ │
│ 515 systemstatusd 0.0 0.0││ │
│ 531 colorsync.displa 0.0 0.0││ │
│ 665 accessoryaccessd 0.0 0.0││ │
│ 677 syncdefaultsd 0.0 0.0││ │
│ 696 SystemUIServer 0.0 0.1││ │
│ 817 PerfPowerTelemet 0.0 0.0││ │
│ 866 findmydevice-use 0.0 0.0││ │
│ 951 systemsoundserve 0.0 0.0││ │
│ 1352 system_installd 0.0 0.0││ │
│ 1397 ThunderboltAcces 0.0 0.0││ │
│ 1408 KonaSynthesizer 0.0 0.1││ │
│ 1409 WardaSynthesizer 0.0 0.0││ │
│ 1438 FindMyDeviceShar 0.0 0.0││ │
│ 1445 com.apple.access 0.0 0.0││ │
│ 1554 systemstats 0.0 0.0││ │
│ 1829 FamilyIntents 0.0 0.0││ │
│ 1843 IFTelemetrySELFI 0.0 0.0││ │
│ 1846 KeyboardSettings 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
┌ cpu history · yes (pid 74232) · 100.1% now · 8/60 samples ───────────────────────────────────────┐
│ ▇█▇▇▇▇│
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
filter: yes
draw 13 · log 0@128B · key (none yet) · mouse (none yet) · 100x30 · theme slate

The window holds eight samples. The two blank columns at the left are its two oldest: the first is the baseline, which reads 0.0% because sysinfo computes CPU usage as a delta between two refreshes and the first look has nothing to subtract; the second is the loop’s first refresh, milliseconds after the baseline, which read a fraction of a percent — under an eighth of the window’s maximum, which is a blank column. The six bars are the refreshes after that. The tallest of them is the second, and it is the one that sets the scale the other five are drawn against; the newest sits one step below it, which is why the title can read 100.1% now while the rightmost bar is not the full block.

Two other things in that frame. The filter line reads filter: yes rather than press / to filter, because the editor is closed but the needle is still active. And the log pane says log empty: no line in the demo log contains the needle, so the pane narrowed to nothing, which is the filter doing the same job in both panes.

? puts the key list in a centred box over the frame. Clear wipes what is underneath first, or the frame would show through the gaps between the overlay’s characters:

use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Clear, Paragraph};
/// Where the overlay sits: a centred box, two thirds of the width and half
/// the height. On a squeezed terminal the box shrinks with it, and an empty
/// area draws nothing rather than complaining.
fn help_overlay(area: Rect) -> Rect {
Rect {
x: area.x + area.width / 6,
y: area.y + area.height / 4,
width: area.width * 2 / 3,
height: area.height / 2,
}
}
/// The overlay's contents: every key, and what it does.
fn help_lines() -> Vec<Line<'static>> {
let rows = [
("j, k, arrows", "move the selection"),
("s", "cycle the sort column"),
("r", "flip the sort direction"),
("/", "filter (enter keeps, esc clears)"),
("space", "pause the tail"),
("pgup, pgdn", "scroll the log"),
("t", "next colour theme"),
("?", "this help"),
("q, ctrl-c", "quit"),
];
rows.iter()
.map(|(key, what)| Line::from(format!(" {key:<14} {what}")))
.collect()
}
fn main() {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| {
// A stand-in for the frame underneath: a header line, and a log
// line where the overlay will cut through it.
frame.render_widget(
Paragraph::new(" rlog · 699 processes · 15.6/64.0 GB resident"),
Rect::new(0, 0, 80, 1),
);
frame.render_widget(
Paragraph::new("2026-10-25T10:00:01Z INFO rlog starting, following app.log"),
Rect::new(0, 12, 80, 1),
);
let overlay = help_overlay(frame.area());
frame.render_widget(Clear, overlay);
frame.render_widget(
Paragraph::new(help_lines()).block(Block::bordered().title(" keys ")),
overlay,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..buffer.area.height {
let row: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect();
println!("{}", row.trim_end());
}
}
rlog · 699 processes · 15.6/64.0 GB resident
┌ keys ─────────────────────────────────────────────┐
│ j, k, arrows move the selection │
│ s cycle the sort column │
│ r flip the sort direction │
│ / filter (enter keeps, esc clears) │
│ space pause the tail │
2026-10-25T10│ pgup, pgdn scroll the log │
│ t next colour theme │
│ ? this help │
│ q, ctrl-c quit │
│ │
└───────────────────────────────────────────────────┘

The log line runs under the overlay and comes out the other side: the columns the box covers are gone, and the rest is intact. Any key press closes the overlay, and the key does nothing else, so a reader cannot move the selection by accident through it; Ctrl-C is the exception, because a user who presses it means it. The overlay is drawn last in draw, which is the whole trick — a widget rendered after the frame covers it — and Clear is what makes the covering clean. Nine key rows in a fifteen-row box leaves four rows the box never writes, and in the real frame those rows have the table’s right border and the log pane underneath them:

rlog · 697 processes · 15.6/64.0 GB resident
┌ processes [23/697] · cpu desc ─────┐┌ log [1/2] ─────────────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│ 944 photoanalysisd 0.0 0.4││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│ 716 fileproviderd 0.0 0.1││ │
│ 893 SogouInput 0.0 0.4││ │
│ 836 axassetsd 0.0 0.0││ │
│ 669 univers┌ keys ──────────────────────────────────────────────────────────┐ │
│ 697 trustd │ j, k, arrows move the selection │ │
│ 1418 jetpack│ s cycle the sort column │ │
│ 758 sharing│ r flip the sort direction │ │
│ 854 amsacco│ / filter (enter keeps, esc clears) │ │
│ 964 WorkBud│ space pause the tail │ │
│ 852 remindd│ pgup, pgdn scroll the log │ │
│ 8568 VTDecod│ t next colour theme │ │
│ 709 iconser│ ? this help │ │
│ 1417 visuali│ q, ctrl-c quit │ │
│ 2655 sandbox│ │ │
│ 741 corespo│ │ │
│ 630 php-fpm│ │ │
│ 900 php-fpm│ │ │
│ 899 php-fpm└────────────────────────────────────────────────────────────────┘ │
│ 901 php-fpm 0.0 0.0││ │
│▸1 launchd 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
┌ cpu history · launchd (pid 1) · 0.0% now · 4/60 samples ─────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
press / to filter
draw 4 · log 2@128B · key (none yet) · mouse (none yet) · 100x30 · theme slate

They are blank. Without Clear they would be whatever the frame drew there — a border and a fragment of the log line, showing through the gaps in the overlay. That is the whole job of Clear: a unit struct with no style and no text, rendered first so the widget after it starts from nothing.

Three palettes live in one const array, and t walks them: slate (cyan), ember (yellow) and mono (no colour at all, attributes only). The accent lands on the header, the pane borders, the pane titles, the table’s header row, the selected row and the sparkline bars; the layout, the data and the keys do not move. The status line names the current palette, so the change is visible in a text capture even though the colours are not:

rlog · 697 processes · 15.6/64.0 GB resident
┌ processes [27/697] · cpu desc ─────┐┌ log [1/2] paused ──────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│ 827 mobiletimerd 0.0 0.0││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│ 758 sharingd 0.0 0.2││ │
│ 648 cfprefsd 0.0 0.0││ │
│ 810 mediaanalysisd 0.0 0.9││ │
│ 734 NotificationCent 0.0 0.1││ │
│ 884 networkservicepr 0.0 0.0││ │
│ 965 WorkBuddy Helper 0.0 0.1││ │
│ 741 corespotlightd 0.0 0.4││ │
│ 723 WallpaperImageEx 0.0 1.2││ │
│ 738 replayd 0.0 0.0││ │
│ 675 lsd 0.0 0.0││ │
│ 852 remindd 0.0 0.1││ │
│ 1417 visualintelligen 0.0 0.2││ │
│ 1431 amsengagementd 0.0 0.1││ │
│ 2655 sandbox-cli 0.0 0.0││ │
│ 8568 VTDecoderXPCServ 0.0 0.1││ │
│ 900 php-fpm 0.0 0.0││ │
│ 630 php-fpm 0.0 0.0││ │
│ 901 php-fpm 0.0 0.0││ │
│ 899 php-fpm 0.0 0.0││ │
│▸1 launchd 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
┌ cpu history · launchd (pid 1) · 0.0% now · 4/60 samples ─────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
press / to filter
draw 5 · log 2@128B · key (none yet) · mouse (none yet) · 100x30 · theme ember

That capture was taken after t and space: the status line reads theme ember, and the log pane’s title says paused. The theme cycle is three lines in apply — app.theme = app.theme.next() — and the theme is a plain Copy value, so draw borrows it and changes nothing.

Step 1 promised space freezes the tail so a burst can be read; steps 5 and 6 built the tail without it. Now it exists: paused gates the poll in run, the file keeps growing, the offset stays where it is, and the catch-up read when the key is pressed again is bounded by the same 64 KiB-per-poll cap as any other — a gigabyte of backlog arrives over several polls, not in one frame. The pane title says paused so the state is never a guess.

The same pass fixed a promise step 6 could not keep. Its article says Enter closes the editor and keeps the filter active, and for half a second that was true — but the editor closing dropped the value with it, so the next refresh rebuilt the process view from an empty needle and the table silently unfiltered, while the log view kept its stale indices and stayed empty. The fix separates the two ideas that were one field: filter holds the active needle, editing says whether the editor is open. Closing the editor now leaves the needle in place and the view survives the refresh. The capture two sections up is the proof: the editor was closed with Enter more than three seconds before that frame was taken, across four refreshes, and the table is still narrowed to 28 rows with filter: yes on the filter line.

key what it does
j, k, arrows move the selection
s cycle the sort column: cpu, mem, pid
r flip the sort direction
/ open the filter (enter keeps it, esc clears it)
space pause the tail
PgUp, PgDn scroll the log pane by a screenful
t next colour theme
? the help overlay
q, Ctrl-C quit

A wheel notch still scrolls the pane under the pointer. The history pane has no offset to move, so a notch over it — or over the header, the filter line or the status line — scrolls nothing.

Terminal window
cargo run -- ./app.log

The frame starts on the baseline refresh, so the first second shows 0.0% everywhere and a history of zeros — one sample per process per second, all of them zero until there are two refreshes to subtract; the real numbers and the first non-zero samples arrive together a second later.

cargo test runs the 36 tests. Ten of them are new: the ring’s bound and its prune, the series padding and scaling, the pane’s title with a subject and without one, the overlay’s rect and the keys it swallows, the theme cycle, space and the paused title, and a wheel notch over the history pane scrolling nothing. One old test changed shape — closing_the_filter_with_esc_clears_it_and_enter_keeps_it now also asserts that the view survives the refresh after Enter, which is the bug described above.

The whole file at this step:

This block is marked compile-only for the same reason as step 2’s: a program that enters raw mode needs a terminal to enter it, and the sandbox that checks the article’s blocks has none. The runs above are the real ones, from the same binary cargo run builds.

Step 8 is the release article: what a terminal program needs from its environment, and how to hand someone a binary. The track overview keeps the full list current as articles land, step 5 is the state the tail came from, and step 6 is the table this step’s history follows.