Skip to content
Select theme

A sortable process list

Step 6 of 8. Step 5 left a frame with two panes: a log tail following a real file, and a process table of fourteen fixed demo rows. This step replaces the demo rows with the machine’s real process table, read through sysinfo on a slow one-second schedule, and builds the interaction on top of it: a selected row, s and r for sorting, and a / filter that narrows the table and the log pane at once. Start from tag tui-ch05; the end state is tagged tui-ch06.

Cargo.toml
[package]
name = "rlog"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.29"
ratatui = "0.30"
sysinfo = "0.39"
tui-input = "0.15"
nucleo = "0.5"

sysinfo is the process table: one System handle, refreshed on demand. tui-input owns the filter editor — the value, the cursor, word-wise deletion — as a piece of state rather than as a pile of escape sequences. nucleo is the fuzzy matcher from the Helix editor, and it is what makes ngx find nginx. The versions this step resolved are sysinfo 0.39.6, tui-input 0.15.5 and nucleo 0.5.0; the fence asks for the minor version, so patch releases come through, and Cargo.lock pins what was run.

System::new() is deliberately not System::new_all(). new_all reads the process table and the disks, the network interfaces and the components, and this program needs the first plus the memory summary. One call does the work:

use sysinfo::{ProcessesToUpdate, System};
fn main() {
let mut system = System::new();
// The baseline: `sysinfo` computes CPU usage as a delta between two
// refreshes, so this first one establishes the zero.
system.refresh_processes(ProcessesToUpdate::All, true);
println!("{} processes on the first look", system.processes().len());
}
691 processes on the first look

The true is remove_dead_processes: a pid that has gone by the next refresh is dropped from the map rather than lingering with stale numbers. The refresh is the expensive read — every process on the machine is walked — which is why it runs on its own schedule, PROCESS_REFRESH, once a second. The tick that waits for a keypress stays at 250 ms; the two clocks are the same shape as step 5’s log poll, and for the same reason.

The snapshot is copied out of sysinfo into owned rows. Process borrows from the System, and a frame rendered from a borrow would tie the render to the refresh; the copy also means the tests can build a table without touching the machine:

/// One row of the process table.
#[derive(Debug, Clone)]
struct ProcessRow {
/// The process id. It is also the selection's identity.
pid: u32,
name: String,
/// Percent of one CPU. A multi-threaded process can exceed 100.
cpu: f32,
/// Resident set size in bytes.
rss: u64,
}
fn main() {
use sysinfo::{ProcessesToUpdate, System};
let mut system = System::new();
system.refresh_processes(ProcessesToUpdate::All, true);
let mut rows: Vec<ProcessRow> = system
.processes()
.values()
.map(|process| ProcessRow {
pid: process.pid().as_u32(),
name: process.name().to_string_lossy().into_owned(),
cpu: process.cpu_usage(),
rss: process.memory(),
})
.collect();
println!("{} rows copied", rows.len());
// The busiest process first, which is the order the table starts in.
rows.sort_by(|a, b| b.cpu.total_cmp(&a.cpu).then(a.pid.cmp(&b.pid)));
if let Some(top) = rows.first() {
println!("busiest: {} (pid {}) at {:.1}%", top.name, top.pid, top.cpu);
}
}
691 rows copied
busiest: launchd (pid 1) at 0.0%

Two things that output shows. Every process reads 0.0%, because a single refresh is only a baseline: sysinfo subtracts one refresh from the next, so with one look there is nothing to subtract and the “busiest” row is simply the lowest pid. The real percentages arrive with the first scheduled refresh a second later. And the name is the whole executable name, not the truncated column the table draws — ProtectedCloudKeySyncing shows as ProtectedCloudKey, which matters when the filter matches against it.

The table sorts on three columns, and s cycles through them: CPU, memory, pid. r flips the direction. The sort itself is one sort_by, and the interesting part is the tiebreak:

cpu desc -> ["rlog", "nvim", "node", "nginx"]
mem desc -> ["node", "rlog", "nvim", "nginx"]
mem asc -> ["nginx", "nvim", "rlog", "node"]
pid asc -> ["nginx", "node", "nvim", "rlog"]
selected Some(988) is row 2 of 4
after node got busy, selected Some(988) is row 3 of 4
after node exited, the selection moved to Some(1042)

The tiebreak is not decoration. Most of the processes on a quiet machine sit at exactly 0.0% CPU, and sysinfo hands them over in hash-map order, which changes between refreshes. Without the pid as a tiebreak those rows would shuffle every second and the table would be unreadable; with it, the order is reproducible, which is also what makes the sort testable.

The selection is stored as a pid, not as a row number. The table reorders itself under the cursor every second — CPU percentages are the sort key, and they move — so an index would highlight a different process each refresh. move_selection does the walk: it finds the selected pid’s position, moves it by delta, clamps the target to 0..view.len(), and stores the pid of the row it landed on — with no selection yet, j starts at the top and k at the bottom.

keep_selection_visible is the other half: a selection below the window scrolls the table down by the fewest rows that bring it into view, and one above scrolls back up. Without it, moving the selection with j would walk it straight off the bottom of the pane.

The first refresh selects the top row, so the highlight is on screen before the first keypress. On this machine that has a visible consequence: the baseline refresh sees 0.0% everywhere, so the top row is the lowest pid — launchd, pid 1 — and when the real numbers arrive a second later the selection stays on launchd and the table scrolls down to follow it. That is the design working, not a bug: the selection is the process, and the process did not move.

/ opens the filter. While it is open, keys belong to the editor: q is a letter, backspace deletes, and Ctrl-C is still the way out. tui-input does the editing — Input::handle_event takes the crossterm event and returns whether the value changed — and every change rebuilds both views:

use nucleo::{Config, Matcher, Utf32Str};
/// One fuzzy match. `needle` is the query, `haystack` is the row or line.
///
/// `nucleo`'s matcher works on UTF-32 strings it borrows, so the two buffers
/// are scratch space owned by the caller and reused across calls — building a
/// matcher allocates a large matrix, and running a filter over a thousand rows
/// should not mean building anything per row.
fn fuzzy_match(
matcher: &mut Matcher,
needle_buf: &mut Vec<char>,
haystack_buf: &mut Vec<char>,
needle: &str,
haystack: &str,
) -> bool {
if needle.is_empty() {
return true;
}
let needle = Utf32Str::new(needle, needle_buf);
let haystack = Utf32Str::new(haystack, haystack_buf);
matcher.fuzzy_match(haystack, needle).is_some()
}
fn main() {
let rows = [
(1042, "rlog"),
(988, "nvim"),
(812, "node"),
(381, "nginx"),
];
let mut matcher = Matcher::new(Config::DEFAULT);
let mut needle_buf = Vec::new();
let mut haystack_buf = Vec::new();
// A process row is matched against "pid name", so the needle can be
// either: `ngx` finds nginx, `1042` finds a pid.
for needle in ["", "ngx", "vim", "1042", "zzz"] {
let hits: Vec<&str> = rows
.iter()
.filter(|(pid, name)| {
fuzzy_match(
&mut matcher,
&mut needle_buf,
&mut haystack_buf,
needle,
&format!("{pid} {name}"),
)
})
.map(|(_, name)| *name)
.collect();
println!("{needle:>6} -> {hits:?}");
}
// The log pane runs the same matcher over whole lines.
let lines = [
"2026-10-25T10:00:01Z INFO rlog starting, following app.log",
"2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, processes 1s",
];
for needle in ["log", "tick", "nginx"] {
let hits: Vec<&str> = lines
.iter()
.filter(|line| fuzzy_match(&mut matcher, &mut needle_buf, &mut haystack_buf, needle, line))
.copied()
.collect();
println!("{needle:>6} -> {} line(s)", hits.len());
}
}
-> ["rlog", "nvim", "node", "nginx"]
ngx -> ["nginx"]
vim -> ["nvim"]
1042 -> ["rlog"]
zzz -> []
log -> 2 line(s)
tick -> 1 line(s)
nginx -> 0 line(s)

Fuzzy matching is subsequence matching with a score: the needle’s characters have to appear in order, not next to each other. That is why log matches ProtectedCloudKeySyncing — l, o, g in order, near the end — and why the process pane and the log pane narrow together: both views are rebuilt from the same needle, the rows against pid name and the lines against themselves.

Two keys close the editor, and they do different things on purpose. Enter closes it and keeps the filter active, so a narrowed table stays narrowed while the keyboard goes back to moving the selection. Esc closes it and clears the value. Either way the filter line goes back to its dim press / to filter hint.

The layout grows two rows. A one-row header carries the machine summary — how many processes, how much of the memory they hold — and a one-row filter line sits between the panes and the status line, so the editor is always somewhere to type. The layout function returns five rects now, and it is printed in full in the demo block below.

Three of the five rows are Length(1), so they take their height first and the panes share what is left: a squeezed terminal can never push the filter line or the status line off the screen. The table’s title now carries the sort state as well as the position — [11/34] · cpu desc in the capture below — and the selected row is drawn by the widget, with a ▸ marker in front of it and the row reversed.

The block below renders the same frame into ratatui’s in-memory terminal, with the machine’s numbers replaced by fixed ones, at the size the captures below were taken at:

use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState};
use tui_input::Input;
/// At or above this width the panes sit side by side; below it they stack.
const WIDE: u16 = 80;
/// The same five-rect split the real frame uses: header, table, log, filter
/// line, status line.
fn layout(area: Rect) -> [Rect; 5] {
let [header, main, filter, status] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
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, filter, status]
} else {
let [table, log] =
Layout::vertical([Constraint::Length(11), Constraint::Fill(1)]).areas(main);
[header, table, log, filter, status]
}
}
fn main() {
// Fixed rows, in the order the real table would hold them after a refresh
// with the filter "log" active and CPU sorting: pid, name, cpu, mem%.
let processes = [
(69395, "rlog", 5.2, 0.0),
(2660, "log", 0.1, 0.0),
(423, "loginwindow", 0.1, 0.2),
(348, "logd", 0.0, 0.0),
(385, "syslogd", 0.0, 0.0),
(407, "logind", 0.0, 0.0),
];
let log_lines = [
"2026-10-25T10:00:01Z INFO rlog starting, following app.log",
"2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, processes 1s",
];
// The filter editor, open, with "log" typed into it.
let filter = Input::from("log");
// The second row is selected.
let selected = Some(1);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| {
let [header_area, table_area, log_area, filter_area, status_area] =
layout(frame.area());
frame.render_widget(
Paragraph::new(Line::from(format!(
" rlog · {} processes · 1.2/64.0 GB resident",
processes.len()
)))
.style(Style::new().add_modifier(Modifier::BOLD)),
header_area,
);
let rows = processes.iter().map(|(pid, name, cpu, mem)| {
Row::new(vec![
Cell::from(pid.to_string()),
Cell::from(*name),
Cell::from(Line::from(format!("{cpu:.1}")).alignment(Alignment::Right)),
Cell::from(Line::from(format!("{mem:.1}")).alignment(Alignment::Right)),
])
});
let table = Table::new(
rows,
[
Constraint::Length(6),
Constraint::Min(8),
Constraint::Length(5),
Constraint::Length(5),
],
)
.header(Row::new(vec![
Cell::from("PID"),
Cell::from("COMMAND"),
Cell::from(Line::from("CPU%").alignment(Alignment::Right)),
Cell::from(Line::from("MEM%").alignment(Alignment::Right)),
]))
.block(Block::bordered().title(format!(
" processes [1/{}] · cpu desc ",
processes.len()
)))
.highlight_symbol("▸")
.row_highlight_style(Style::new().add_modifier(Modifier::REVERSED));
let mut table_state = TableState::default();
table_state.select(selected);
frame.render_stateful_widget(table, table_area, &mut table_state);
let log = List::new(log_lines.iter().map(|line| ListItem::new(*line))).block(
Block::bordered().title(format!(" log [1/{}] ", log_lines.len())),
);
let mut log_state = ListState::default();
frame.render_stateful_widget(log, log_area, &mut log_state);
frame.render_widget(
Paragraph::new(Line::from(format!("/ {}", filter.value()))),
filter_area,
);
frame.render_widget(
Paragraph::new(Line::from(
"draw 8 · log 2@128B · key (none yet) · mouse (none yet) · 100x30",
)),
status_area,
);
})
.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 · 6 processes · 1.2/64.0 GB resident
┌ processes [1/6] · cpu desc ────────┐┌ log [1/2] ─────────────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│ 69395 rlog 5.2 0.0││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│▸2660 log 0.1 0.0││ │
│ 423 loginwindow 0.1 0.2││ │
│ 348 logd 0.0 0.0││ │
│ 385 syslogd 0.0 0.0││ │
│ 407 logind 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
/ log
draw 8 · log 2@128B · key (none yet) · mouse (none yet) · 100x30

The reversed style does not show in a text dump — the buffer holds symbols, not colours — but the ▸ does, and the pane titles show the sort and the filtered count. In the real terminal the whole row is reversed, which is what makes the selection findable at a glance.

apply grows a new arm at the top: while the filter is open, events go to the editor and Ctrl-C is the only key that still quits. Below that arm the table gains six keys:

key what it does
j, Down move the selection down one row
k, Up move the selection up one row
s cycle the sort column: cpu, mem, pid
r flip the sort direction
/ open the filter
PgUp, PgDn scroll the log pane by a screenful

PgUp and PgDn finally do what step 1 promised them: the wheel already scrolled the log pane, and now the keyboard scrolls it too, by visible - 1 lines so the reader keeps their place. The wheel itself is unchanged, except that both clamps now run against the view — a wheel notch over a filtered table scrolls the filtered rows, not the ones the filter hid.

Terminal window
cargo run -- ./app.log

The first capture is a real run on a pseudo-terminal, 100 columns by 30 rows, a second and a half after startup — long enough for two process refreshes:

rlog · 692 processes · 15.2/64.0 GB resident
┌ processes [10/692] · cpu desc ─────┐┌ log [1/2] ─────────────────────────────────────────────────┐
│ PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│ 648 cfprefsd 0.3 0.0││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│ 695 MenuBarAgent 0.3 0.1││ │
│ 2660 log 0.2 0.0││ │
│ 423 loginwindow 0.1 0.2││ │
│ 843 redis-server 0.1 0.0││ │
│ 70682 python3 0.1 0.0││ │
│ 1299 sandbox-center 0.1 0.1││ │
│ 698 Finder 0.0 0.2││ │
│ 716 fileproviderd 0.0 0.1││ │
│ 893 SogouInput 0.0 0.4││ │
│ 1300 appstoreagent 0.0 0.1││ │
│ 836 axassetsd 0.0 0.0││ │
│ 758 sharingd 0.0 0.2││ │
│ 1282 node 0.0 0.2││ │
│ 852 remindd 0.0 0.1││ │
│ 709 iconservicesagen 0.0 0.1││ │
│ 1353 betterleaks 0.0 0.0││ │
│ 741 corespotlightd 0.0 0.4││ │
│ 901 php-fpm 0.0 0.0││ │
│ 2655 sandbox-cli 0.0 0.0││ │
│ 900 php-fpm 0.0 0.0││ │
│ 630 php-fpm 0.0 0.0││ │
│ 899 php-fpm 0.0 0.0││ │
│▸1 launchd 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
press / to filter
draw 2 · log 2@128B · key (none yet) · mouse (none yet) · 100x30

692 processes, 15.2 GB of the machine’s 64 GB resident. The marker sits on launchd for the reason the selection section describes: the baseline refresh saw all zeros, pid 1 came out on top, and the selection followed it when the real numbers arrived. The title says [10/692] · cpu desc, and the status line reports the tail’s stand.

The second capture is the same program with the filter open: /, log, Enter — a later run, so the count differs by one. Thirty-four of the 691 processes match, both log lines do, and the panes narrowed together:

rlog · 691 processes · 15.2/64.0 GB resident
┌ processes [11/34] · cpu desc ──────┐┌ log [1/2] ─────────────────────────────────────────────────┐
│PID COMMAND CPU% MEM%││2026-10-25T10:00:01Z INFO rlog starting, following app.log │
│69395 rlog 5.2 0.0││2026-10-25T10:00:01Z INFO tick 250ms, log poll 250ms, proce│
│2660 log 0.1 0.0││ │
│423 loginwindow 0.1 0.2││ │
│348 logd 0.0 0.0││ │
│385 syslogd 0.0 0.0││ │
│407 logind 0.0 0.0││ │
│417 com.apple.cmio.re 0.0 0.0││ │
│419 logd_helper 0.0 0.0││ │
│523 com.apple.CodeSig 0.0 0.0││ │
│532 com.apple.ColorSy 0.0 0.0││ │
│538 WirelessRadioMana 0.0 0.0││ │
│624 modelcatalogd 0.0 0.0││ │
│706 com.apple.quicklo 0.0 0.1││ │
│744 CoreLocationAgent 0.0 0.1││ │
│765 findmylocateagent 0.0 0.0││ │
│820 intelligentroutin 0.0 0.0││ │
│829 com.west2online.C 0.0 0.0││ │
│831 spotlightknowledg 0.0 0.3││ │
│858 LoginUserService 0.0 0.0││ │
│896 localspeechrecogn 0.0 0.0││ │
│1277 ProtectedCloudKey 0.0 0.0││ │
│1286 com.apple.ColorSy 0.0 0.0││ │
│1349 com.apple.AppStor 0.0 0.0││ │
│1367 iCloudNotificatio 0.0 0.0││ │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
press / to filter
draw 8 · log 2@128B · key (none yet) · mouse (none yet) · 100x30

The names are truncated to fit the column — ProtectedCloudKey is ProtectedCloudKeySyncing — but the filter matched the full name, which is why a row whose visible text does not contain log is still in the view.

cargo test runs the 26 tests: the four sort orders, the pid tiebreak, the selection following a pid across a refresh, a process that exits handing the selection to the top row, the clamps at both ends, the selection staying visible, the fuzzy matches, both panes narrowing, Enter and Esc, the sort keys, and the tail and layout tests from step 5, unchanged except for the two extra rows the frame now has.

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 7 keeps a ring buffer of CPU samples per process and draws the sparkline in the bottom pane, with the selected process’s history as the subject. The track overview keeps the full list current as articles land, step 5 is the state this step started from, and step 4 is the layout the new rows were added to.