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.
The three new crates
Section titled “The three new crates”[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.
Reading the table
Section titled “Reading the table”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 lookThe 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 copiedbusiest: 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.
Sorting
Section titled “Sorting”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:
/// One row of the process table: pid, name, cpu percent, resident bytes.#[derive(Debug, Clone)]struct ProcessRow { pid: u32, name: String, cpu: f32, rss: u64,}
/// Which column sorts the table. `s` cycles through them in this order.#[derive(Debug, Clone, Copy, PartialEq, Eq)]enum SortColumn { Pid, Cpu, Mem,}
impl SortColumn { fn next(self) -> Self { match self { SortColumn::Pid => SortColumn::Cpu, SortColumn::Cpu => SortColumn::Mem, SortColumn::Mem => SortColumn::Pid, } }
fn label(self) -> &'static str { match self { SortColumn::Pid => "pid", SortColumn::Cpu => "cpu", SortColumn::Mem => "mem", } }}
/// Sort in place. The chosen column is the primary key and the pid breaks/// every tie, so the order is a function of the data and nothing else — two/// processes with the same CPU do not swap places between refreshes.fn sort_processes(rows: &mut [ProcessRow], column: SortColumn, descending: bool) { rows.sort_by(|a, b| { let primary = match column { SortColumn::Pid => a.pid.cmp(&b.pid), SortColumn::Cpu => a.cpu.total_cmp(&b.cpu), SortColumn::Mem => a.rss.cmp(&b.rss), }; let primary = if descending { primary.reverse() } else { primary }; primary.then(a.pid.cmp(&b.pid)) });}
/// Where the selected process sits in the current order. The selection is a/// pid, so this is a search, not an index.fn selection_index(selected: Option<u32>, rows: &[ProcessRow]) -> Option<usize> { let pid = selected?; rows.iter().position(|row| row.pid == pid)}
fn order(rows: &[ProcessRow]) -> Vec<&str> { rows.iter().map(|row| row.name.as_str()).collect()}
fn main() { let mut rows = vec![ ProcessRow { pid: 1042, name: "rlog".into(), cpu: 8.1, rss: 40_000_000 }, ProcessRow { pid: 988, name: "nvim".into(), cpu: 6.4, rss: 20_000_000 }, ProcessRow { pid: 812, name: "node".into(), cpu: 3.0, rss: 60_000_000 }, ProcessRow { pid: 381, name: "nginx".into(), cpu: 0.0, rss: 5_000_000 }, ];
// The default: CPU, descending — the column that changes while you watch. let mut column = SortColumn::Cpu; let mut descending = true; for step in 0..4 { sort_processes(&mut rows, column, descending); println!( "{:<4} {:<4} -> {:?}", column.label(), if descending { "desc" } else { "asc" }, order(&rows) ); if step == 1 { // `r` flips the direction; the column stays where it is. descending = !descending; } else { // `s` moves to the next column. column = column.next(); } }
// The selection is a pid, not a row number. node gets busy between two // refreshes, the order changes underneath, and the selection — nvim — // has to stay on nvim. sort_processes(&mut rows, SortColumn::Cpu, true); let mut selected = Some(988); println!( "\nselected {selected:?} is row {} of {}", selection_index(selected, &rows).unwrap() + 1, rows.len() ); rows[2].cpu = 61.0; sort_processes(&mut rows, SortColumn::Cpu, true); println!( "after node got busy, selected {selected:?} is row {} of {}", selection_index(selected, &rows).unwrap() + 1, rows.len() );
// And when the selected process exits, the top row takes over. selected = Some(812); rows.retain(|row| row.pid != 812); sort_processes(&mut rows, SortColumn::Cpu, true); if selection_index(selected, &rows).is_none() { selected = rows.first().map(|row| row.pid); } println!("after node exited, the selection moved to {selected:?}");}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 4after node got busy, selected Some(988) is row 3 of 4after 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.
A selection that follows the process
Section titled “A selection that follows the process”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.
The fuzzy filter
Section titled “The fuzzy filter”/ 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 frame
Section titled “The frame”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││ │└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘/ logdraw 8 · log 2@128B · key (none yet) · mouse (none yet) · 100x30The 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.
The keys
Section titled “The keys”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.
Running it
Section titled “Running it”cargo run -- ./app.logThe 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 filterdraw 2 · log 2@128B · key (none yet) · mouse (none yet) · 100x30692 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 filterdraw 8 · log 2@128B · key (none yet) · mouse (none yet) · 100x30The 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:
//! rlog — a terminal UI that follows a log file and watches the process table.//!//! Step 6 of the track: the demo process rows are gone. The table is read from//! the machine with `sysinfo` on a slow one-second schedule, a selected row is//! highlighted, `s` and `r` choose and flip the sort column, and `/` opens a//! fuzzy filter built on `tui-input` and `nucleo` that narrows the process//! table and the log pane at once. The selection is stored as a pid, so it//! survives the refresh that reorders the table; the sort, the selection and//! the filter are pure functions of the state, and that is where this step's//! tests live.
use std::collections::VecDeque;use std::env;use std::fs::File;use std::io::{self, ErrorKind, Read, Seek, SeekFrom};use std::path::PathBuf;use std::time::{Duration, Instant};
use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,};use crossterm::execute;use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,};use nucleo::{Config, Matcher, Utf32Str};use ratatui::prelude::*;use ratatui::widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState};use sysinfo::{ProcessesToUpdate, System};use tui_input::backend::crossterm::EventHandler;use tui_input::Input;
/// How long the loop waits for an event before going around again. One tick of/// the program's clock: short enough that a keypress feels immediate, long/// enough that an idle program wakes four times a second instead of spinning.const TICK: Duration = Duration::from_millis(250);
/// How often the log file is re-read. A log is cheap to poll (one `stat`, one/// short read) and a line that appears a quarter of a second late is still/// live.const LOG_POLL: Duration = Duration::from_millis(250);
/// How often the process table is re-read. This is the expensive data: every/// process on the machine is walked, and CPU usage is a delta between two/// refreshes, so a once-a-second schedule is both the floor `sysinfo` wants/// and the fastest rate a reader can read a changing table at.const PROCESS_REFRESH: Duration = Duration::from_secs(1);
/// The most bytes one poll reads out of the file. A log that grew by gigabytes/// since the last look is drained over several polls rather than pulled into/// memory in one go; the offset keeps the place.const LOG_READ_LIMIT: u64 = 64 * 1024;
/// How many lines the tail keeps. A pane shows a couple of dozen at a time; a/// thousand is more history than the screen can scroll back through, and the/// bound is what stops a busy log from growing the program without end.const LOG_CAPACITY: usize = 1000;
/// At or above this width the panes sit side by side; below it they stack./// 80 columns is the width a terminal opens at before anyone resizes it, so/// the first thing a reader sees is the stacked layout until the window is/// dragged wider.const WIDE: u16 = 80;
/// How many lines one notch of the wheel scrolls. Three traverses a long log/// without turning the wheel into a paging key.const SCROLL_STEP: isize = 3;
/// The file `rlog` follows when no path is given.const DEFAULT_LOG: &str = "./app.log";
/// One row of the process table. Everything the table shows about a process,/// copied out of `sysinfo` at refresh time: the snapshot is owned data, so/// `draw` borrows it and the tests can build one without touching the machine.#[derive(Debug, Clone)]struct ProcessRow { /// The process id. It is also the selection's identity: the row order /// changes every refresh, and the pid is what stays put. pid: u32, name: String, /// Percent of one CPU, as `sysinfo` computes it between two refreshes. /// A multi-threaded process can exceed 100. cpu: f32, /// Resident set size in bytes. rss: u64,}
/// Which column the table is sorted by. `s` cycles through them in this order.#[derive(Debug, Clone, Copy, PartialEq, Eq)]enum SortColumn { Pid, Cpu, Mem,}
impl SortColumn { /// The next column in the cycle. fn next(self) -> Self { match self { SortColumn::Pid => SortColumn::Cpu, SortColumn::Cpu => SortColumn::Mem, SortColumn::Mem => SortColumn::Pid, } }
/// The column's name, as the pane title spells it. fn label(self) -> &'static str { match self { SortColumn::Pid => "pid", SortColumn::Cpu => "cpu", SortColumn::Mem => "mem", } }}
/// The whole state of the program. `draw` stays a pure function of it: the/// render borrows the state back and changes nothing.struct App { /// Frames actually sent to the terminal. Not the same as the number of /// loop passes: an idle program makes thousands of those and one of these. draws: u64, /// Set whenever anything visible changed; the loop clears it as it draws. dirty: bool, /// The last key press, `None` until the first one arrives. last_key: Option<KeyEvent>, /// The last mouse event, `None` until the mouse does something. last_mouse: Option<MouseEvent>, /// Columns and rows, as of the last resize. size: (u16, u16), /// The file being followed. log_path: PathBuf, /// The tail itself: the last lines read, oldest first, bounded by /// `LOG_CAPACITY`. log_lines: VecDeque<String>, /// How far into the file the tail has read. Everything before it has been /// shown; everything after it has not been read yet. log_offset: u64, /// How many times the file was truncated underneath the program. log_truncations: u64, /// The file could not be opened on the last poll. Not an error — a log /// that has not been created yet is a normal thing to wait for. log_missing: bool, /// Which log lines the pane shows: indices into `log_lines`, in order. /// Every line when no filter is active, the matching ones when it is. log_view: Vec<usize>, /// The first log line on screen. log: ListState, /// The `sysinfo` handle. It owns no snapshot of its own — `processes` /// below is the snapshot — but it owns the state CPU usage is computed /// from, which is why it lives for the whole run. system: System, /// The last process snapshot, kept in the current sort order. processes: Vec<ProcessRow>, /// Which processes the pane shows: indices into `processes`, in order. /// All of them when no filter is active, the matching ones when it is. view: Vec<usize>, /// The selected process, by pid. The first refresh already selects the /// top row, so the highlight is on screen before the first keypress. selected: Option<u32>, /// The first process row on screen. table: TableState, /// Which column sorts the table, and which way. sort: SortColumn, sort_desc: bool, /// The filter editor, `Some` while `/` is open. Closing it keeps the /// value: the filter stays active until it is cleared. filter: Option<Input>, /// The fuzzy matcher, and the two scratch buffers `Utf32Str` borrows. /// They live in the state because a matcher is expensive to build and the /// filter runs on every row of every refresh. matcher: Matcher, needle_buf: Vec<char>, haystack_buf: Vec<char>,}
fn main() -> io::Result<()> { // The file to follow is the first argument; `./app.log` is the default so // a bare `cargo run` has something to tail. let log_path = env::args() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(DEFAULT_LOG));
// A panic while the terminal is in raw mode leaves it unusable: every line // the shell prints afterwards lands wherever the cursor happens to sit. // Restore the terminal first, then let the default hook print the panic. let default_panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = leave_full_screen(); default_panic_hook(info); }));
enable_raw_mode()?; // Mouse tracking is a terminal mode like the alternate screen: the program // asks for it, and it has to give it back on the way out. execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
// The size starts from the terminal itself; `Event::Resize` keeps it // current after that. let mut app = App { draws: 0, dirty: true, last_key: None, last_mouse: None, size: crossterm::terminal::size()?, log_path, log_lines: VecDeque::new(), log_offset: 0, log_truncations: 0, log_missing: false, log_view: Vec::new(), log: ListState::default(), system: System::new(), processes: Vec::new(), view: Vec::new(), selected: None, table: TableState::default(), sort: SortColumn::Cpu, sort_desc: true, filter: None, matcher: Matcher::new(Config::DEFAULT), needle_buf: Vec::new(), haystack_buf: Vec::new(), };
// The baseline refresh. `sysinfo` computes CPU usage as a delta between // two refreshes, so the first frame shows 0.0% everywhere and the real // numbers arrive with the first scheduled refresh a second later. refresh_processes(&mut app);
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; let result = run(&mut terminal, &mut app);
leave_full_screen()?; result}
/// Undo what `main` set up: mouse tracking off, the alternate screen away, then/// raw mode. The order matters — raw mode is what makes the escape sequences/// arrive intact, so it goes last.fn leave_full_screen() -> io::Result<()> { execute!( io::stdout(), DisableMouseCapture, LeaveAlternateScreen )?; disable_raw_mode()}
/// The render loop. Each pass, in order: poll the log file if its interval has/// come around, refresh the process table if its slower one has, draw if/// anything is dirty, then wait one tick for an event. `q`, `Esc` and `Ctrl-C`/// end the loop and give the terminal back; every other event lands in the/// state, where the next frame reports it.fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> { // Both due immediately: the first frame already shows the file and the // process table as they stand. let mut log_due = Instant::now(); let mut process_due = Instant::now();
loop { if Instant::now() >= log_due { poll_log(app)?; log_due = Instant::now() + LOG_POLL; } if Instant::now() >= process_due { refresh_processes(app); process_due = Instant::now() + PROCESS_REFRESH; }
if app.dirty { app.dirty = false; app.draws += 1; terminal.draw(|frame| draw(frame, app))?; }
if event::poll(TICK)? { if !apply(app, event::read()?) { break; } } } Ok(())}
/// Fold one event into the state. Returns `false` when the event is one that/// ends the program, so the caller can break out of the loop.////// Every arm that a reader can see sets `dirty`: the status line reports the/// last key and mouse event, a resize reflows every pane, a keypress moves the/// selection or the sort, and a wheel notch moves an offset. An event that/// changes nothing visible — a key release, say — leaves the flag alone.fn apply(app: &mut App, event: Event) -> bool { // While the filter editor is open, keys belong to it: `q` is a letter, not // a quit. `Ctrl-C` still ends the program — it arrives as a key event in // raw mode, and a user who presses it means it. if app.filter.is_some() { if let Event::Key(key) = &event { if key.kind == KeyEventKind::Press { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return false; } match key.code { // Enter closes the editor and keeps the filter active. KeyCode::Enter => { close_filter(app, false); return true; } // Esc closes the editor and clears the filter. KeyCode::Esc => { close_filter(app, true); return true; } _ => {} } } } if let Some(input) = app.filter.as_mut() { let changed = input.handle_event(&event).map(|s| s.value).unwrap_or(false); if changed { rebuild_view(app); rebuild_log_view(app); app.dirty = true; } } return true; }
match event { Event::Key(key) => { if key.kind == KeyEventKind::Press { if is_quit(key) { return false; } match key.code { KeyCode::Char('j') | KeyCode::Down => { move_selection(app, 1); } KeyCode::Char('k') | KeyCode::Up => { move_selection(app, -1); } KeyCode::Char('s') => { app.sort = app.sort.next(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(app); app.dirty = true; } KeyCode::Char('r') => { app.sort_desc = !app.sort_desc; sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(app); app.dirty = true; } KeyCode::Char('/') => { open_filter(app); } KeyCode::PageUp => { scroll_log(app, -page_step(app)); } KeyCode::PageDown => { scroll_log(app, page_step(app)); } _ => { app.last_key = Some(key); app.dirty = true; } } } } Event::Mouse(mouse) => { app.last_mouse = Some(mouse); scroll_wheel(app, mouse); app.dirty = true; } Event::Resize(w, h) => { app.size = (w, h); app.dirty = true; } // Focus and paste events only arrive if the program asks for them with // their own commands; rlog asks for neither, so this arm is unreachable // in practice. _ => {} } true}
/// Read the whole process table into the snapshot, sort it, and rebuild the/// view. This is the expensive read, and it runs on the slow schedule.fn refresh_processes(app: &mut App) { app.system.refresh_processes(ProcessesToUpdate::All, true); app.system.refresh_memory(); app.processes = app .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(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(app);
// The selection is a pid, and a refresh is exactly when a pid can stop // existing. A process that exited leaves the selection pointing at // nothing, and the top row takes over. A process that is still alive but // hidden by the filter keeps its selection: it comes back when the filter // is cleared. let alive = app .selected .is_some_and(|pid| app.processes.iter().any(|row| row.pid == pid)); if !alive { app.selected = app.view.first().map(|&i| app.processes[i].pid); } app.dirty = true;}
/// Sort the snapshot in place. The primary key is the chosen column; the pid/// breaks every tie, so two processes with the same CPU keep a stable,/// reproducible order instead of one that depends on hash-map iteration.fn sort_processes(rows: &mut [ProcessRow], column: SortColumn, descending: bool) { rows.sort_by(|a, b| { let primary = match column { SortColumn::Pid => a.pid.cmp(&b.pid), SortColumn::Cpu => a.cpu.total_cmp(&b.cpu), SortColumn::Mem => a.rss.cmp(&b.rss), }; let primary = if descending { primary.reverse() } else { primary }; primary.then(a.pid.cmp(&b.pid)) });}
/// Rebuild the process view: the indices of the rows that pass the filter, in/// the order the table shows them. An empty filter keeps everything.fn rebuild_view(app: &mut App) { let needle = filter_needle(app); let App { matcher, needle_buf, haystack_buf, processes, view, .. } = app; *view = (0..processes.len()) .filter(|&i| { fuzzy_match( matcher, needle_buf, haystack_buf, &needle, &row_haystack(&processes[i]), ) }) .collect(); clamp_table(app);}
/// What a process row is matched against: the pid and the name, joined by a/// space, so a needle can be either — `ngx` finds nginx, `1042` finds a pid.fn row_haystack(row: &ProcessRow) -> String { format!("{} {}", row.pid, row.name)}
/// The active needle, or the empty string when no filter is set. An empty/// needle matches everything, which is what "no filter" means.fn filter_needle(app: &App) -> String { app.filter .as_ref() .map(|input| input.value().to_string()) .unwrap_or_default()}
/// 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()}
/// Open the filter editor. The value starts empty, so opening it shows/// everything until the first character arrives.fn open_filter(app: &mut App) { app.filter = Some(Input::default()); app.dirty = true;}
/// Close the filter editor. `clear` also empties the value, which is what/// `Esc` does; `Enter` keeps the filter active so a narrowed table stays/// narrowed while the keyboard goes back to moving the selection.fn close_filter(app: &mut App, clear: bool) { app.filter = None; if clear { rebuild_view(app); rebuild_log_view(app); } app.dirty = true;}
/// Move the selection by `delta` rows in the current view, clamped at both/// ends. The selection is a pid, not an index: the table reorders itself under/// the cursor every second, and a pid is what keeps the same process/// highlighted while its CPU percentage moves.fn move_selection(app: &mut App, delta: isize) { if app.view.is_empty() { app.selected = None; app.dirty = true; return; } // No selection yet: `j` starts at the top, `k` at the bottom. let start = selection_index(app) .map(|index| index as isize) .unwrap_or(if delta < 0 { app.view.len() as isize } else { -1 }); let target = (start + delta).clamp(0, app.view.len() as isize - 1) as usize; app.selected = Some(app.processes[app.view[target]].pid); keep_selection_visible(app); app.dirty = true;}
/// Where the selected process sits in the current view, if it is in it.fn selection_index(app: &App) -> Option<usize> { let pid = app.selected?; app.view.iter().position(|&i| app.processes[i].pid == pid)}
/// Pull the scroll offset to the selection after it moved. A selection below/// the window scrolls down by the fewest rows that bring it into view; one/// above scrolls back up to it.fn keep_selection_visible(app: &mut App) { let Some(index) = selection_index(app) else { return; }; let visible = table_visible(app.size); if visible == 0 { return; } let offset = app.table.offset_mut(); if index < *offset { *offset = index; } else if index >= *offset + visible { *offset = index + 1 - visible; }}
/// Pull the table's scroll offset back into range after the view changed./// A filter that hides the rows below a scrolled pane would otherwise leave/// the offset pointing past the end.fn clamp_table(app: &mut App) { let visible = table_visible(app.size); let last = app.view.len().saturating_sub(visible); let offset = app.table.offset_mut(); if *offset > last { *offset = last; app.dirty = true; } keep_selection_visible(app);}
/// Read whatever the file has grown by since the last poll, and notice the two/// things that can happen to a file underneath a running program: it can/// vanish, and it can be truncated.////// The file is opened fresh each poll rather than held open. A held handle/// would follow the old inode after the file is replaced, and the program/// would tail a file nobody can see any more; re-opening is what makes a/// replaced file show up as new contents at the same path.fn poll_log(app: &mut App) -> io::Result<()> { let mut file = match File::open(&app.log_path) { Ok(file) => file, Err(error) if error.kind() == ErrorKind::NotFound => { // Waiting for a log that does not exist yet is not a failure. The // tail and the offset stay as they are, and the status line says // the file is missing until it appears. if !app.log_missing { app.log_missing = true; app.dirty = true; } return Ok(()); } Err(error) => return Err(error), }; if app.log_missing { // It came back: whatever grew while it was away is read below. app.log_missing = false; app.dirty = true; }
let len = file.metadata()?.len(); if len < app.log_offset { // Shorter than where the tail stopped reading: the file was truncated // (or replaced by a shorter one). Start over from the beginning. app.log_offset = 0; app.log_lines.clear(); app.log_truncations += 1; rebuild_log_view(app); app.dirty = true; } if len == app.log_offset { return Ok(()); }
file.seek(SeekFrom::Start(app.log_offset))?; let mut chunk = Vec::new(); // `take` caps the read at `LOG_READ_LIMIT` bytes; a file that grew by more // than that since the last poll is finished on the next one. (&file).take(LOG_READ_LIMIT).read_to_end(&mut chunk)?;
// Only the bytes up to the last newline are lines. A writer mid-line // leaves a partial line at the end of the chunk, and consuming it would // advance the offset past bytes that do not make a line yet — the rest // arrives on the next poll and the two halves are joined there. let usable = complete_len(&chunk); app.log_offset += usable as u64; for line in String::from_utf8_lossy(&chunk[..usable]).lines() { push_log(app, line.to_string()); } rebuild_log_view(app); Ok(())}
/// How many bytes at the front of `chunk` form whole lines: everything through/// the last newline. Zero when there is no newline at all.fn complete_len(chunk: &[u8]) -> usize { match chunk.iter().rposition(|byte| *byte == b'\n') { Some(last_newline) => last_newline + 1, None => 0, }}
/// Append one line to the tail, dropping the oldest when the bound is reached./// Dropping the front moves the window: the same lines are one line earlier in/// the buffer, so the offset follows them down.fn push_log(app: &mut App, line: String) { if app.log_lines.len() == LOG_CAPACITY { app.log_lines.pop_front(); *app.log.offset_mut() = app.log.offset().saturating_sub(1); } app.log_lines.push_back(line);}
/// Rebuild the log view: the indices of the lines the pane shows, and pull the/// scroll offset back into range. The filter runs over the whole line, because/// a log line is one string and there is no field boundary to match against.fn rebuild_log_view(app: &mut App) { let needle = filter_needle(app); let App { matcher, needle_buf, haystack_buf, log_lines, log_view, .. } = app; *log_view = (0..log_lines.len()) .filter(|&i| fuzzy_match(matcher, needle_buf, haystack_buf, &needle, &log_lines[i])) .collect(); let visible = log_visible(app.size); let last = log_view.len().saturating_sub(visible); let offset = app.log.offset_mut(); if *offset > last { *offset = last; app.dirty = true; }}
/// Split the screen into the frame: a one-row header, the two panes, the/// filter line and the status line. Wide terminals put the panes side by side;/// narrow ones stack them, the table above the log.////// The header, the filter line and the status line are `Length(1)`: they take/// their rows first, and the panes share what is left, so a squeezed pane can/// never push them off the screen.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 { // The table is `Length(11)` — header, eight processes and the border — // and the log takes the rest. A `Min(11)` floor would not do here: the // solver shares the slack beyond the minimum with the `Fill` pane, so // the table would keep growing as the terminal grows taller. let [table, log] = Layout::vertical([Constraint::Length(11), Constraint::Fill(1)]).areas(main); [header, table, log, filter, status] }}
/// How many log lines the pane can show at the current size: the border takes/// a row off the top and one off the bottom.fn log_visible(size: (u16, u16)) -> usize { let [_, _, log_area, _, _] = layout(Rect::new(0, 0, size.0, size.1)); log_area.height.saturating_sub(2) as usize}
/// How many process rows the pane can show at the current size: the border/// takes a row off the top and one off the bottom, and the header row takes/// one more.fn table_visible(size: (u16, u16)) -> usize { let [_, table_area, _, _, _] = layout(Rect::new(0, 0, size.0, size.1)); table_area.height.saturating_sub(3) as usize}
/// How many lines one `PgUp`/`PgDn` scrolls: a screenful, less the row that/// stays put so the reader keeps their place.fn page_step(app: &App) -> isize { log_visible(app.size).saturating_sub(1) as isize}
/// Scroll the log pane by `delta` lines, clamped at both ends.fn scroll_log(app: &mut App, delta: isize) { let visible = log_visible(app.size); scroll_offset(app.log.offset_mut(), app.log_view.len(), visible, delta); app.dirty = true;}
/// A wheel notch scrolls the pane the pointer is over. The layout is the hit/// map: the same rects `draw` renders into decide which scroll offset moves,/// so the two can never disagree about where a pane is.fn scroll_wheel(app: &mut App, mouse: MouseEvent) { let delta = match mouse.kind { MouseEventKind::ScrollUp => -SCROLL_STEP, MouseEventKind::ScrollDown => SCROLL_STEP, _ => return, }; let [_, table_area, log_area, _, _] = layout(Rect::new(0, 0, app.size.0, app.size.1)); let at = Position::new(mouse.column, mouse.row); if table_area.contains(at) { // The table spends its first inner row on the header, so the rows // that fit are three fewer than the pane is tall. let visible = table_area.height.saturating_sub(3) as usize; scroll_offset(app.table.offset_mut(), app.view.len(), visible, delta); } else { let visible = log_area.height.saturating_sub(2) as usize; scroll_offset(app.log.offset_mut(), app.log_view.len(), visible, delta); }}
/// Move a scroll offset by `delta` lines, clamped at both ends. The upper/// clamp keeps the last line on screen: the offset never passes/// `len - visible`, and when the pane is taller than the content that is zero/// — a pane that just grew taller pulls a stale offset back with it.fn scroll_offset(offset: &mut usize, len: usize, visible: usize, delta: isize) { let last = len.saturating_sub(visible) as isize; *offset = (*offset as isize + delta).clamp(0, last) as usize;}
/// The whole frame: header, two panes, the filter line and the status line,/// laid out from the terminal's current size. Both states are `Copy`, so/// `draw` takes the app by shared reference and renders from copies — the/// offset and selection the widgets write back while rendering land on the/// copies, and the state changes only when an event says so.fn draw(frame: &mut Frame, app: &App) { let [header_area, table_area, log_area, filter_area, status_area] = layout(frame.area());
// The header answers "is this box busy?" before anything is selected: // how many processes there are, and how much of the memory they hold. let gib = |bytes: u64| bytes as f64 / (1024 * 1024 * 1024) as f64; let used = gib(app.system.used_memory()); let total = gib(app.system.total_memory()); frame.render_widget( Paragraph::new(Line::from(format!( " rlog · {} processes · {used:.1}/{total:.1} GB resident", app.processes.len() ))) .style(Style::new().add_modifier(Modifier::BOLD)), header_area, );
let rows = app.view.iter().map(|&i| { let process = &app.processes[i]; Row::new(vec![ Cell::from(process.pid.to_string()), Cell::from(process.name.as_str()), Cell::from(Line::from(format!("{:.1}", process.cpu)).alignment(Alignment::Right)), Cell::from( Line::from(format!("{:.1}", percent(process.rss, app.system.total_memory()))) .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 {} ", table_title(app)))) // The selection is drawn by the widget: a marker in front of the row and // the row reversed, so the eye finds it without reading the numbers. .highlight_symbol("▸") .row_highlight_style(Style::new().add_modifier(Modifier::REVERSED)); let mut table_state = app.table; table_state.select(selection_index(app)); frame.render_stateful_widget(table, table_area, &mut table_state);
let log = List::new( app.log_view .iter() .map(|&i| ListItem::new(app.log_lines[i].as_str())), ) .block(Block::bordered().title(format!(" log {} ", log_title(app)))); let mut log_state = app.log; frame.render_stateful_widget(log, log_area, &mut log_state);
draw_filter(frame, filter_area, app);
frame.render_widget( Paragraph::new(Line::from(format!( "draw {} · {} · key {} · mouse {} · {}x{}", app.draws, describe_log(app), describe_key(app.last_key.as_ref()), describe_mouse(app.last_mouse.as_ref()), app.size.0, app.size.1, ))), status_area, );}
/// The filter line. Closed, it is a dim hint that says what `/` does; open, it/// shows the value with the cursor after it, and the terminal cursor is placed/// there so typing looks like typing.fn draw_filter(frame: &mut Frame, area: Rect, app: &App) { let (text, style) = match &app.filter { Some(input) => ( format!("/ {}", input.value()), Style::new().add_modifier(Modifier::BOLD), ), None => ( "press / to filter".to_string(), Style::new().add_modifier(Modifier::DIM), ), }; frame.render_widget(Paragraph::new(Line::from(text)).style(style), area);
if let Some(input) = &app.filter { // The row holds "/ " plus the value. `visual_scroll` says how much of // the value is scrolled off to the left at this width; the cursor is // drawn where it falls inside the visible part. let width = area.width.saturating_sub(3) as usize; let scroll = input.visual_scroll(width); let x = input.visual_cursor().max(scroll) - scroll + 2; frame.set_cursor_position((area.x + x as u16, area.y)); }}
/// The process pane's title: where the top row stands, how many rows the view/// holds, and which column sorts and which way.fn table_title(app: &App) -> String { if app.view.is_empty() { return "empty".to_string(); } format!( "[{}/{}] · {} {}", app.table.offset() + 1, app.view.len(), app.sort.label(), if app.sort_desc { "desc" } else { "asc" } )}
/// The log pane's title: where the tail stands, and how many times it has been/// restarted. An empty view says so rather than claiming a first line — which/// includes the case of a filter that matches nothing.fn log_title(app: &App) -> String { if app.log_view.is_empty() { return "empty".to_string(); } let mut title = format!("[{}/{}]", app.log.offset() + 1, app.log_view.len()); if app.log_truncations > 0 { title.push_str(&format!(" trunc {}", app.log_truncations)); } title}
/// Resident bytes as a percentage of the machine's total memory.fn percent(part: u64, whole: u64) -> f64 { if whole == 0 { return 0.0; } part as f64 / whole as f64 * 100.0}
/// Name the log pane for the status line: where the tail stands, or that the/// file is not there.fn describe_log(app: &App) -> String { if app.log_missing { "log missing".to_string() } else { format!("log {}@{}B", app.log_view.len(), app.log_offset) }}
/// The keys that end the program. `Ctrl-C` arrives as a key event in raw mode,/// not as a signal, so it has to be handled here or not at all.fn is_quit(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))}
/// Name a key the way the status line shows it. Modifiers come first, the key/// itself second; `None` before the first press.fn describe_key(key: Option<&KeyEvent>) -> String { let Some(key) = key else { return "(none yet)".to_string(); }; let mut name = match key.code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Backspace => "backspace".to_string(), KeyCode::Tab => "tab".to_string(), KeyCode::Up => "up".to_string(), KeyCode::Down => "down".to_string(), KeyCode::Left => "left".to_string(), KeyCode::Right => "right".to_string(), KeyCode::PageUp => "pgup".to_string(), KeyCode::PageDown => "pgdn".to_string(), KeyCode::Home => "home".to_string(), KeyCode::End => "end".to_string(), other => format!("{other:?}").to_lowercase(), }; if key.modifiers.contains(KeyModifiers::CONTROL) { name = format!("ctrl-{name}"); } if key.modifiers.contains(KeyModifiers::ALT) { name = format!("alt-{name}"); } name}
/// Name a mouse event for the status line: what happened, then where. Both/// coordinates are zero-based, counted from the top-left cell.fn describe_mouse(mouse: Option<&MouseEvent>) -> String { let Some(mouse) = mouse else { return "(none yet)".to_string(); }; let kind = match mouse.kind { MouseEventKind::Down(button) => format!("press {}", button_name(button)), MouseEventKind::Up(button) => format!("release {}", button_name(button)), MouseEventKind::Drag(button) => format!("drag {}", button_name(button)), MouseEventKind::Moved => "move".to_string(), MouseEventKind::ScrollUp => "scroll up".to_string(), MouseEventKind::ScrollDown => "scroll down".to_string(), MouseEventKind::ScrollLeft => "scroll left".to_string(), MouseEventKind::ScrollRight => "scroll right".to_string(), }; format!("{} at {},{}", kind, mouse.column, mouse.row)}
fn button_name(button: MouseButton) -> &'static str { match button { MouseButton::Left => "left", MouseButton::Right => "right", MouseButton::Middle => "middle", }}
#[cfg(test)]mod tests { use std::io::Write as _;
use ratatui::backend::TestBackend;
use super::*; use tui_input::InputRequest;
/// An app pointed at a scratch file, the state `main` builds at startup. fn app(log_path: PathBuf) -> App { let mut app = App { draws: 0, dirty: true, last_key: None, last_mouse: None, size: (100, 30), log_path, log_lines: VecDeque::new(), log_offset: 0, log_truncations: 0, log_missing: false, log_view: Vec::new(), log: ListState::default(), system: System::new(), processes: Vec::new(), view: Vec::new(), selected: None, table: TableState::default(), sort: SortColumn::Cpu, sort_desc: true, filter: None, matcher: Matcher::new(Config::DEFAULT), needle_buf: Vec::new(), haystack_buf: Vec::new(), }; refresh_processes(&mut app); // The baseline refresh above reads the real machine; the tests that // install their own snapshot start from the startup state instead, // so nothing the test machine happens to be running leaks in. app.processes.clear(); app.view.clear(); app.selected = None; app.table = TableState::default(); app.dirty = false; app }
/// A scratch file with its own name, so parallel tests do not share one. fn scratch(name: &str) -> PathBuf { std::env::temp_dir().join(format!("rlog-test-{name}.log")) }
/// A fixed snapshot, so the sort and selection tests do not read the /// machine the test happens to run on. fn rows() -> Vec<ProcessRow> { vec![ ProcessRow { pid: 1042, name: "rlog".into(), cpu: 8.1, rss: 40_000_000 }, ProcessRow { pid: 988, name: "nvim".into(), cpu: 6.4, rss: 20_000_000 }, ProcessRow { pid: 812, name: "node".into(), cpu: 3.0, rss: 60_000_000 }, ProcessRow { pid: 381, name: "nginx".into(), cpu: 0.0, rss: 5_000_000 }, ] }
/// The same rows in a different order, as a refresh would leave them: /// node got busy, the rest kept their numbers. fn refreshed() -> Vec<ProcessRow> { vec![ ProcessRow { pid: 812, name: "node".into(), cpu: 61.0, rss: 60_000_000 }, ProcessRow { pid: 1042, name: "rlog".into(), cpu: 8.1, rss: 40_000_000 }, ProcessRow { pid: 988, name: "nvim".into(), cpu: 6.4, rss: 20_000_000 }, ProcessRow { pid: 381, name: "nginx".into(), cpu: 0.0, rss: 5_000_000 }, ] }
#[test] fn sorting_puts_the_busiest_process_first() { let mut table = rows(); // The default: CPU, descending. sort_processes(&mut table, SortColumn::Cpu, true); let names: Vec<&str> = table.iter().map(|row| row.name.as_str()).collect(); assert_eq!(names, ["rlog", "nvim", "node", "nginx"]);
// `r` flips it without touching the column. sort_processes(&mut table, SortColumn::Cpu, false); let names: Vec<&str> = table.iter().map(|row| row.name.as_str()).collect(); assert_eq!(names, ["nginx", "node", "nvim", "rlog"]);
// `s` moves to the next column, and the direction survives the move. sort_processes(&mut table, SortColumn::Mem, true); let pids: Vec<u32> = table.iter().map(|row| row.pid).collect(); assert_eq!(pids, [812, 1042, 988, 381]);
// pid sorts ascending, and the pid breaks every tie, so the order // never depends on anything but the data. sort_processes(&mut table, SortColumn::Pid, false); let pids: Vec<u32> = table.iter().map(|row| row.pid).collect(); assert_eq!(pids, [381, 812, 988, 1042]); }
#[test] fn equal_keys_keep_the_pid_order() { let mut table = vec![ ProcessRow { pid: 40, name: "b".into(), cpu: 1.0, rss: 1 }, ProcessRow { pid: 10, name: "a".into(), cpu: 1.0, rss: 1 }, ProcessRow { pid: 20, name: "c".into(), cpu: 1.0, rss: 1 }, ]; sort_processes(&mut table, SortColumn::Cpu, true); let pids: Vec<u32> = table.iter().map(|row| row.pid).collect(); assert_eq!(pids, [10, 20, 40]); }
#[test] fn the_selection_follows_the_pid_not_the_row() { let mut app = app(scratch("selection")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app);
// nvim is the second row: two presses from no selection reach it. move_selection(&mut app, 1); move_selection(&mut app, 1); assert_eq!(app.selected, Some(988)); assert_eq!(selection_index(&app), Some(1));
app.processes = refreshed(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); assert_eq!(app.selected, Some(988)); // nvim dropped from second to third, and the index followed the pid. assert_eq!(selection_index(&app), Some(2)); }
#[test] fn a_process_that_exits_hands_the_selection_to_the_top_row() { let mut app = app(scratch("exited")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); move_selection(&mut app, 1); move_selection(&mut app, 1); move_selection(&mut app, 1); assert_eq!(app.selected, Some(812));
// node (812) is gone from the next snapshot; the top row takes over. app.processes = rows().into_iter().filter(|row| row.pid != 812).collect(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); let alive = app .selected .is_some_and(|pid| app.processes.iter().any(|row| row.pid == pid)); assert!(!alive); app.selected = app.view.first().map(|&i| app.processes[i].pid); assert_eq!(app.selected, Some(1042)); }
#[test] fn moving_the_selection_clamps_at_both_ends() { let mut app = app(scratch("clamp-move")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app);
// No selection yet: the first `j` lands on the first row, and `k` // from there has nowhere to go. move_selection(&mut app, 1); assert_eq!(app.selected, Some(1042)); move_selection(&mut app, -1); assert_eq!(app.selected, Some(1042));
// `j` walks down: rlog (8.1), nvim (6.4), node (3.0), nginx (0.0), // and one more press has nowhere to go either. move_selection(&mut app, 3); assert_eq!(app.selected, Some(381)); move_selection(&mut app, 1); assert_eq!(app.selected, Some(381));
// An empty view leaves nothing selected. app.view.clear(); move_selection(&mut app, 1); assert_eq!(app.selected, None); }
#[test] fn the_selection_stays_visible_when_the_table_scrolls() { let mut app = app(scratch("visible")); // Thirty rows, of which the pane shows 24 at 100x30: a 27-row table // minus the border pair and the header. for i in 1..=30u32 { app.processes.push(ProcessRow { pid: 1000 + i, name: format!("worker{i}"), cpu: i as f32, rss: 1, }); } sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); assert_eq!(table_visible(app.size), 24);
// Select the last row — worker1, pid 1001, because CPU sorts // descending: the offset jumps so it is on screen. move_selection(&mut app, 100); assert_eq!(app.selected, Some(1001)); assert_eq!(app.table.offset(), 30 - table_visible(app.size)); assert!(selection_index(&app).unwrap() >= app.table.offset());
// Selecting the first row scrolls back to the top. move_selection(&mut app, -100); assert_eq!(app.selected, Some(1030)); assert_eq!(app.table.offset(), 0); }
#[test] fn the_filter_matches_names_and_pids_fuzzily() { let mut matcher = Matcher::new(Config::DEFAULT); let mut needle_buf = Vec::new(); let mut haystack_buf = Vec::new();
// The step 1 example: `ngx` finds nginx. assert!(fuzzy_match( &mut matcher, &mut needle_buf, &mut haystack_buf, "ngx", "381 nginx" )); // A needle that is not in the row does not. assert!(!fuzzy_match( &mut matcher, &mut needle_buf, &mut haystack_buf, "nginx", "381 ngin" )); // The pid is part of the haystack, so a number works too. assert!(fuzzy_match( &mut matcher, &mut needle_buf, &mut haystack_buf, "1042", "1042 rlog" )); // No filter keeps everything. assert!(fuzzy_match( &mut matcher, &mut needle_buf, &mut haystack_buf, "", "anything at all" )); }
#[test] fn the_filter_narrows_both_panes_at_once() { let mut app = app(scratch("filter")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); for i in 1..=6 { app.log_lines.push_back(format!("line {i}")); } rebuild_log_view(&mut app);
// No filter: every row and every line is in view. assert_eq!(app.view.len(), 4); assert_eq!(app.log_view.len(), 6);
app.filter = Some(Input::from("vim")); rebuild_view(&mut app); rebuild_log_view(&mut app); // nvim, and nothing else, in the table; no log line matches "vim". let names: Vec<&str> = app.view.iter().map(|&i| app.processes[i].name.as_str()).collect(); assert_eq!(names, ["nvim"]); assert!(app.log_view.is_empty());
// A needle that matches log lines: the pane narrows to them. app.filter = Some(Input::from("line 3")); rebuild_view(&mut app); rebuild_log_view(&mut app); assert!(app.view.is_empty()); assert_eq!(app.log_view.len(), 1); assert_eq!(app.log_lines[app.log_view[0]], "line 3"); }
#[test] fn closing_the_filter_with_esc_clears_it_and_enter_keeps_it() { let mut app = app(scratch("close")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); open_filter(&mut app); if let Some(input) = app.filter.as_mut() { input.handle(InputRequest::InsertChar('v')); input.handle(InputRequest::InsertChar('i')); input.handle(InputRequest::InsertChar('m')); } rebuild_view(&mut app); assert_eq!(app.view.len(), 1);
// Enter: the editor closes, the narrowed table stays narrowed. close_filter(&mut app, false); assert!(app.filter.is_none()); assert_eq!(app.view.len(), 1);
// Esc: the value goes, and everything is back. open_filter(&mut app); if let Some(input) = app.filter.as_mut() { input.handle(InputRequest::InsertChar('n')); } close_filter(&mut app, true); assert!(app.filter.is_none()); rebuild_view(&mut app); assert_eq!(app.view.len(), 4); }
#[test] fn the_sort_keys_move_through_the_columns() { let mut app = app(scratch("sort-keys")); app.processes = rows(); assert_eq!(app.sort, SortColumn::Cpu); assert!(app.sort_desc); // `s` cycles cpu → mem → pid → cpu, and `r` flips the direction. let event = |code: KeyCode| Event::Key(KeyEvent::new(code, KeyModifiers::NONE)); assert!(apply(&mut app, event(KeyCode::Char('s')))); assert_eq!(app.sort, SortColumn::Mem); assert!(apply(&mut app, event(KeyCode::Char('s')))); assert_eq!(app.sort, SortColumn::Pid); assert!(apply(&mut app, event(KeyCode::Char('r')))); assert!(!app.sort_desc); // And a keypress is still reported by the status line. assert!(apply(&mut app, event(KeyCode::Char('x')))); assert_eq!(app.last_key.unwrap().code, KeyCode::Char('x')); }
#[test] fn a_chunk_ending_in_a_newline_is_consumed_whole() { assert_eq!(complete_len(b"one\ntwo\n"), 8); assert_eq!(complete_len(b"one\ntwo"), 4); assert_eq!(complete_len(b""), 0); }
#[test] fn a_partial_line_waits_for_the_next_poll() { // A writer halfway through a line: the bytes are in the chunk, but // they are not a line yet, so the offset does not move past them. let mut app = app(scratch("partial")); std::fs::write(&app.log_path, b"whole line\nhalf a li").unwrap(); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 1); assert_eq!(app.log_offset, 11);
// The rest of the line arrives on the next poll and the halves join. let mut file = std::fs::OpenOptions::new() .append(true) .open(&app.log_path) .unwrap(); file.write_all(b"ne\n").unwrap(); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 2); assert_eq!(app.log_offset, 23); assert_eq!(app.log_lines[1], "half a line"); std::fs::remove_file(&app.log_path).unwrap(); }
#[test] fn a_growing_file_is_read_from_where_the_tail_stopped() { let path = scratch("grow"); std::fs::write(&path, b"first\n").unwrap(); let mut app = app(path.clone()); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 1); assert_eq!(app.log_offset, 6);
let mut file = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); file.write_all(b"second\nthird\n").unwrap(); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 3); assert_eq!(app.log_offset, 19); assert_eq!(app.log_lines[2], "third"); std::fs::remove_file(&path).unwrap(); }
#[test] fn a_truncated_file_starts_over() { let path = scratch("truncate"); std::fs::write(&path, b"alpha\nbeta\n").unwrap(); let mut app = app(path.clone()); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 2);
// The file is emptied and refilled with something shorter: the tail // notices the length went backwards and reads from the top again. std::fs::write(&path, b"gamma\n").unwrap(); poll_log(&mut app).unwrap(); assert_eq!(app.log_view.len(), 1); assert_eq!(app.log_lines[0], "gamma"); assert_eq!(app.log_offset, 6); assert_eq!(app.log_truncations, 1); std::fs::remove_file(&path).unwrap(); }
#[test] fn a_missing_file_is_waited_for_not_an_error() { let mut app = app(scratch("missing")); poll_log(&mut app).unwrap(); assert!(app.log_missing); assert!(app.dirty); assert_eq!(app.log_view.len(), 0);
// The file appears with contents: the next poll picks them up. std::fs::write(&app.log_path, b"late line\n").unwrap(); poll_log(&mut app).unwrap(); assert!(!app.log_missing); assert_eq!(app.log_view.len(), 1); std::fs::remove_file(&app.log_path).unwrap(); }
#[test] fn the_tail_is_bounded_and_the_window_follows() { let mut app = app(scratch("capacity")); for i in 1..=LOG_CAPACITY + 5 { push_log(&mut app, format!("line {i}")); } assert_eq!(app.log_lines.len(), LOG_CAPACITY); assert_eq!(app.log_lines.front().unwrap(), "line 6");
// A pane scrolled five lines down keeps showing the same lines: the // front fell off, so the same window is one line earlier in the buffer. *app.log.offset_mut() = 5; push_log(&mut app, "one more".to_string()); assert_eq!(app.log.offset(), 4); assert_eq!(app.log_lines[4], "line 11"); }
#[test] fn a_shrunk_tail_pulls_the_offset_back() { let mut app = app(scratch("clamp")); for i in 1..=30 { push_log(&mut app, format!("line {i}")); } // At 100x30 the pane shows 25 of the 30 lines, so an offset of 2 is // legal and stays put. *app.log.offset_mut() = 2; app.dirty = false; rebuild_log_view(&mut app); assert_eq!(app.log.offset(), 2); assert!(!app.dirty);
// A truncation empties the tail: the offset comes back with it, and // that is a change the frame has to show. app.log_lines.clear(); rebuild_log_view(&mut app); assert_eq!(app.log.offset(), 0); assert!(app.dirty); }
#[test] fn a_keypress_leaves_the_frame_dirty_and_q_ends_the_loop() { let mut app = app(scratch("apply-key")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); app.dirty = false; // `j` moves the selection, and that is a change the frame shows. assert!(apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) )); assert!(app.dirty); assert!(app.selected.is_some());
// Any other keypress is reported by the status line. app.dirty = false; assert!(apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) )); assert!(app.dirty); assert_eq!(app.last_key.unwrap().code, KeyCode::Char('x'));
// Quitting returns false without needing another frame: everything // the final state has to say has already been drawn. app.dirty = false; assert!(!apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) )); assert!(!app.dirty); }
#[test] fn a_key_pressed_while_the_filter_is_open_types_instead_of_quitting() { let mut app = app(scratch("apply-filter")); open_filter(&mut app); // `q` is a letter now, not a quit. assert!(apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) )); assert_eq!(app.filter.as_ref().unwrap().value(), "q"); // Ctrl-C still ends the program. assert!(!apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) )); }
#[test] fn a_wheel_notch_scrolls_the_log_pane() { let mut app = app(scratch("apply-wheel")); for i in 1..=30 { push_log(&mut app, format!("line {i}")); } rebuild_log_view(&mut app); app.dirty = false; // At 100x30 the log pane starts at column 38; a notch there moves the // log's offset, not the table's. let mouse = MouseEvent { kind: MouseEventKind::ScrollDown, column: 40, row: 4, modifiers: KeyModifiers::NONE, }; assert!(apply(&mut app, Event::Mouse(mouse))); assert_eq!(app.log.offset(), SCROLL_STEP as usize); assert_eq!(app.table.offset(), 0); assert!(app.dirty); }
#[test] fn a_resize_reflows_and_dirties_the_frame() { let mut app = app(scratch("apply-resize")); app.dirty = false; assert!(apply(&mut app, Event::Resize(60, 24))); assert_eq!(app.size, (60, 24)); assert!(app.dirty); }
#[test] fn a_wide_terminal_puts_the_panes_side_by_side() { let [header, table, log, filter, status] = layout(Rect::new(0, 0, 100, 30)); assert_eq!(header, Rect::new(0, 0, 100, 1)); assert_eq!(table, Rect::new(0, 1, 38, 27)); assert_eq!(log, Rect::new(38, 1, 62, 27)); assert_eq!(filter, Rect::new(0, 28, 100, 1)); assert_eq!(status, Rect::new(0, 29, 100, 1)); }
#[test] fn a_narrow_terminal_stacks_the_panes() { let [header, table, log, filter, status] = layout(Rect::new(0, 0, 60, 24)); assert_eq!(header, Rect::new(0, 0, 60, 1)); assert_eq!(table, Rect::new(0, 1, 60, 11)); assert_eq!(log, Rect::new(0, 12, 60, 10)); assert_eq!(filter, Rect::new(0, 22, 60, 1)); assert_eq!(status, Rect::new(0, 23, 60, 1)); }
#[test] fn a_pane_too_small_for_the_log_takes_nothing() { // Six rows leave the panes three after the header, the filter line // and the status line take theirs; the table takes all three and the // log's rect is empty, and rendering an empty rect draws nothing // rather than complaining. let [header, table, log, filter, status] = layout(Rect::new(0, 0, 40, 6)); assert_eq!(header, Rect::new(0, 0, 40, 1)); assert_eq!(table, Rect::new(0, 1, 40, 3)); assert_eq!(log, Rect::new(0, 4, 40, 0)); assert_eq!(filter, Rect::new(0, 4, 40, 1)); assert_eq!(status, Rect::new(0, 5, 40, 1)); }
#[test] fn the_frame_renders_at_every_size() { // The widgets get zero-height areas at the small end; rendering one // is allowed to draw nothing, not to panic. The sweep runs twice, with // a full tail and with an empty one, because a pane with no lines yet // is the state the program starts in. let mut app = app(scratch("render")); app.processes = rows(); sort_processes(&mut app.processes, app.sort, app.sort_desc); rebuild_view(&mut app); move_selection(&mut app, 1); for filled in [false, true] { app.log_lines.clear(); if filled { for i in 1..=30 { app.log_lines.push_back(format!("line {i}")); } } rebuild_log_view(&mut app); for (w, h) in [ (100, 30), (80, 24), (79, 24), (46, 18), (40, 12), (40, 11), (40, 6), (20, 6), (20, 4), (10, 3), (0, 0), ] { app.size = (w, h); let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); terminal.draw(|frame| draw(frame, &app)).unwrap(); } } }
#[test] fn the_log_pane_names_its_position() { let mut app = app(scratch("pane")); for i in 1..=30 { app.log_lines .push_back(format!("2026-10-24T21:00:{i:02}Z INFO line {i}")); } rebuild_log_view(&mut app); let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); terminal.draw(|frame| draw(frame, &app)).unwrap(); let buffer = terminal.backend().buffer(); // The log pane's top border is row 1; the pane titles sit on it. let top: String = (0..buffer.area.width) .map(|x| buffer[(x, 1)].symbol()) .collect(); assert!(top.contains("log [1/30]"), "pane title was {top:?}");
// An empty view says so instead of claiming a first line. app.log_lines.clear(); rebuild_log_view(&mut app); let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); terminal.draw(|frame| draw(frame, &app)).unwrap(); let buffer = terminal.backend().buffer(); let top: String = (0..buffer.area.width) .map(|x| buffer[(x, 1)].symbol()) .collect(); assert!(top.contains("log empty"), "pane title was {top:?}"); }}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.