Refreshing live data without flicker
Step 5 of 8. Step 4 left a frame with
three panes and two piles of fixed demo data in them. This step makes the log pane live: it
follows a real file, remembering its offset, picking up whatever grew, and starting over when the
file is truncated underneath it. The loop that drives it grows two schedules — one tick for
events, one for the file — and a dirty flag between the state and the screen, so a frame is
redrawn only when something a reader can see has changed. The process table is still the fourteen
demo rows of step 4; the real one arrives in step 6. Start from tag tui-ch04; the end state is
tagged tui-ch05.
The dependencies are unchanged from step 4. Ratatui draws, crossterm reads the terminal, and everything this step adds — the file tail, the schedules, the flag — is standard library:
[package]name = "rlog"version = "0.1.0"edition = "2024"
[dependencies]crossterm = "0.29"ratatui = "0.30"Two clocks, one loop
Section titled “Two clocks, one loop”Step 4’s loop did the same thing every pass: draw a frame, then wait up to 250 ms for an event. That shape has two problems once the data is live. An idle program redraws four times a second forever, and nothing in the loop knows when to look at the file.
The fix is to notice that the loop answers two different questions. How long may a keypress
wait? is one — the event read blocks, with a timeout, and the timeout is the tick. How stale
may a line be? is the other, and it belongs to the data: a deadline that comes around on its own
schedule. rlog keeps them as separate constants, TICK and LOG_POLL, and the loop checks the
deadline each pass:
use std::time::{Duration, Instant};
/// How long the loop waits for an event before going around again.const TICK: Duration = Duration::from_millis(250);/// How often the log file is re-read. The demo slows this to 500 ms so the/// two schedules are distinguishable; rlog keeps them equal.const LOG_POLL: Duration = Duration::from_millis(500);
fn main() { // Three lines arrive at these moments, and nothing else happens. let arrivals = [0.3, 0.9, 1.4]; let mut next_arrival = 0; let mut lines = 0;
// The state of the frame, and the flag that gates it. let mut dirty = true; let mut draws = 0; let mut passes = 0; let mut polls = 0;
let start = Instant::now(); let mut log_due = Instant::now(); for _ in 0..8 { passes += 1;
// The data schedule: the deadline comes around on its own clock, and // whatever arrived since the last poll is read in one batch. if Instant::now() >= log_due { polls += 1; log_due = Instant::now() + LOG_POLL; if next_arrival < arrivals.len() && start.elapsed() >= Duration::from_secs_f64(arrivals[next_arrival]) { lines += 1; next_arrival += 1; dirty = true; } }
// The draw gate: only a changed frame is worth sending. if dirty { dirty = false; draws += 1; }
// The event read, with nothing to read: it costs a full tick. std::thread::sleep(TICK); } println!("{passes} passes · {polls} polls · {draws} draws · {lines} lines");}8 passes · 4 polls · 4 draws · 3 linesEight passes, four polls, four draws. The pass count is the tick: eight of them take the two
seconds the demo runs for. The poll count is LOG_POLL, which is twice as slow here, so the
deadline comes around on every other pass. And the draw count is neither — it is one for the
first frame plus one for each batch of lines, because a line that arrives between two polls
waits for the next one and is drawn with whatever else arrived alongside it.
That batching is the point of separating the clocks. A log is cheap to poll: one stat and one
short read. A line that shows up a quarter of a second late is still live. The process table of
step 6 is the opposite — reading it costs real work — and it will get the slower schedule. The
event tick and the data schedules then drift apart by design, and the frame rate stops being
tied to either.
Remembering where you stopped
Section titled “Remembering where you stopped”The tail keeps one number: log_offset, how far into the file it has read. Everything before it
has been shown; everything after it has not been read yet. A poll is four steps — open the file,
ask it how long it is, seek to the offset, read what is there:
use std::fs::OpenOptions;use std::io::{Read, Seek, SeekFrom, Write};
/// How many bytes at the front of `chunk` form whole lines: everything/// through the last newline, and nothing after it.fn complete_len(chunk: &[u8]) -> usize { match chunk.iter().rposition(|byte| *byte == b'\n') { Some(last_newline) => last_newline + 1, None => 0, }}
/// One poll of a log file: read from `offset`, and report the new offset and/// the whole lines that were read. A file that has not grown reads nothing.fn poll(path: &str, offset: u64) -> std::io::Result<(u64, Vec<String>)> { let mut file = std::fs::File::open(path)?; let len = file.metadata()?.len(); if len <= offset { return Ok((offset, Vec::new())); } file.seek(SeekFrom::Start(offset))?; let mut chunk = Vec::new(); (&file).take(64 * 1024).read_to_end(&mut chunk)?; let usable = complete_len(&chunk); let text = String::from_utf8_lossy(&chunk[..usable]).to_string(); Ok(( offset + usable as u64, text.lines().map(str::to_string).collect(), ))}
fn main() -> std::io::Result<()> { let path = std::env::temp_dir().join("rlog-article-demo.log"); // A file with one whole line and one half-written one. std::fs::write(&path, b"first line\nsecond li")?; let mut offset = 0;
for round in 1..=2 { let (next, lines) = poll(path.to_str().unwrap(), offset)?; println!( "poll {round}: offset {offset} -> {next}, {} new line(s)", lines.len() ); for line in &lines { println!(" {line}"); } offset = next; if round == 1 { // The rest of the half-written line, and one more after it. OpenOptions::new() .append(true) .open(&path)? .write_all(b"ne\nthird line\n")?; } }
// A file that has not grown since the last poll reads nothing. let (next, lines) = poll(path.to_str().unwrap(), offset)?; println!("poll 3: offset {offset} -> {next}, {} new line(s)", lines.len()); std::fs::remove_file(&path)?; Ok(())}poll 1: offset 0 -> 11, 1 new line(s) first linepoll 2: offset 11 -> 34, 2 new line(s) second line third linepoll 3: offset 34 -> 34, 0 new line(s)Three details in that output. The first poll reads one line out of a twenty-byte file: the eleven bytes through the newline are a line, and the nine after it are half of one. The second poll reads from byte eleven and gets both halves, joined — the writer finished the line between the two polls. The third poll finds nothing new and moves the offset not at all, which is what makes an idle tail free.
complete_len is what keeps the halves apart. A chunk is consumed only through its last
newline, so a writer caught mid-line leaves the partial bytes unread; they arrive on the next
poll and the line is whole when it is shown. Without it, a line could be split across two
redraws, and the offset would advance past bytes that were never shown.
The read is capped at LOG_READ_LIMIT — 64 KiB — by take. A log that grew by gigabytes since
the last poll is drained over several polls instead of pulled into memory in one go; the offset
keeps the place. A line that is not valid UTF-8 is shown with replacement characters rather
than ending the tail, which is what from_utf8_lossy is for.
The file is opened fresh on every poll rather than held open, and that is a decision rather than an oversight. A held handle follows the old inode when a file is replaced, so the program would tail something no reader can see any more. Re-opening is what makes a replaced file show up as new contents at the same path:
use std::fs::File;use std::io::{Read, Seek, SeekFrom};
fn main() -> std::io::Result<()> { let dir = std::env::temp_dir().join("rlog-inode-check"); std::fs::create_dir_all(&dir)?; let path = dir.join("app.log"); std::fs::write(&path, b"old contents\n")?; let mut held = File::open(&path)?;
// Replace the file: a new inode appears at the same path. std::fs::remove_file(&path)?; std::fs::write(&path, b"new contents\n")?;
held.seek(SeekFrom::Start(0))?; let mut from_held = String::new(); held.read_to_string(&mut from_held)?; let fresh = std::fs::read_to_string(&path)?; println!("held handle reads: {from_held:?}"); println!("fresh open reads: {fresh:?}"); std::fs::remove_dir_all(&dir)?; Ok(())}held handle reads: "old contents\n"fresh open reads: "new contents\n"When the file lies to you
Section titled “When the file lies to you”Two things can happen to a file underneath a running program, and the tail handles both by
comparing lengths. If the file is shorter than the offset, it was truncated — or replaced by
something shorter — and the tail starts over: the offset goes back to zero, the lines are
dropped, and a counter goes up. The counter reaches the pane title, so a restart is visible
rather than silent: log [1/2] trunc 1 below is a tail that has started over once.
If the file cannot be opened at all, that is not an error. A log that has not been created yet
is a normal thing to wait for, so a missing file sets a flag, the status line says log missing,
and the next poll tries again. When the file appears, everything that grew while it was away is
read in one go. What the tail does not do is rotate: a file that is renamed away and a new one
created in its place is out of scope here, and the re-open-per-poll behaviour is what keeps a
replacement visible at all.
A truncation also empties the tail underneath a scrolled pane, so the scroll offset is pulled
back into range after the contents change. clamp_log does that with the same arithmetic the
wheel uses: the last legal offset is len - visible, and an emptied tail makes that zero. Both
of these paths are unit-tested against real files in the temp directory — the tests write, grow,
truncate and delete, and assert what the tail did.
The dirty flag
Section titled “The dirty flag”Every event arm that a reader can see ends by setting dirty: a keypress, because the status
line names the last one; a wheel notch, because an offset moved; a resize, because every pane
reflows. The loop clears the flag as it draws, and counts the draw. The status line reports that
count — draw 4 — not the number of loop passes, because the passes are not the interesting
number any more.
What the flag buys is measurable. The same program, run on a pseudo-terminal for one second and for three, writes exactly the same number of bytes to the terminal:
idle for 1s: 2470 bytesidle for 3s: 2470 bytesNothing. An idle program draws its first frame and then writes nothing at all — no cursor
moves, no repaints, not a space. The demo run below writes 3,429 bytes in total: 2,470 for that
first frame and 959 for the three updates that followed, about 320 bytes each. That is ratatui’s
diff at work: Terminal::draw compares the new frame against the one on screen and sends only
the cells that changed, so an update costs a fraction of a frame.
This is the claim step 1 made
when it promised that step 5 would test it: a ratatui program does not flicker when it redraws,
provided nothing clears the screen by hand. The measurement is the test. rlog contains no
Clear widget and writes no escape sequence of its own — the alternate screen is switched once,
at startup — and the dirty flag then removes the redraw entirely: no draw call, no diff, no
bytes. The flicker a TUI can still show is the kind that comes from a program clearing the
screen between frames, and this one does not have it.
The frame with live data
Section titled “The frame with live data”draw changes in three places. The log list is built from the tail instead of a constant. The
pane title names the position and any restarts. The status line reports the draw count and where
the tail stands — lines read and bytes consumed — instead of a frame count. The block below is
the real draw, rendered into ratatui’s in-memory terminal at the size the captures below were
taken at, with the three lines and the 190-byte offset the real run stood at after its first
append:
use std::collections::VecDeque;use std::path::PathBuf;
use ratatui::backend::TestBackend;use ratatui::prelude::*;use ratatui::widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState};use ratatui::Terminal;
/// At or above this width the panes sit side by side; below it they stack.const WIDE: u16 = 80;
/// The demo process rows of step 4, unchanged.const PROCESSES: &[(&str, &str, f64, f64)] = &[ ("1", "systemd", 0.0, 0.3), ("381", "launchd", 0.2, 0.5), ("742", "WindowServer", 3.1, 2.4), ("1204", "rlog", 1.4, 0.9), ("1583", "cargo", 12.7, 1.1), ("1590", "rustc", 41.3, 3.8), ("1602", "rustc", 38.9, 3.6), ("1711", "firefox", 8.2, 12.4), ("1890", "Code Helper", 2.0, 6.1), ("2044", "node", 0.9, 4.2), ("2287", "tmux", 0.0, 0.2), ("2410", "zsh", 0.0, 0.1), ("2533", "ssh", 0.1, 0.2), ("2680", "top", 0.0, 0.1),];
/// The state the frame is drawn from: the tail, the offsets, the counter.struct App { draws: u64, log_lines: VecDeque<String>, log_offset: u64, log_truncations: u64, log: ListState, table: TableState,}
/// The same split step 4 built: table, log, status line.fn layout(area: Rect) -> [Rect; 3] { let [main, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); if area.width >= WIDE { let [table, log] = Layout::horizontal([Constraint::Percentage(38), Constraint::Fill(1)]).areas(main); [table, log, status] } else { let [table, log] = Layout::vertical([Constraint::Length(11), Constraint::Fill(1)]).areas(main); [table, log, status] }}
fn main() { let mut app = App { draws: 2, log_lines: VecDeque::from([ "2026-10-25T09:00:01Z INFO rlog starting, following app.log".to_string(), "2026-10-25T09:00:01Z INFO tick 250ms, log poll 250ms, dirty flag on".to_string(), "2026-10-25T09:00:02Z INFO first line appended while running".to_string(), ]), log_offset: 190, log_truncations: 0, log: ListState::default(), table: TableState::default(), };
let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap(); terminal .draw(|frame| { let [table_area, log_area, status_area] = layout(frame.area());
let rows = PROCESSES.iter().map(|(pid, command, cpu, mem)| { Row::new(vec![ Cell::from(*pid), Cell::from(*command), 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(5), 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 [{}/{}] ", app.table.offset() + 1, PROCESSES.len() ))); let mut table_state = app.table; frame.render_stateful_widget(table, table_area, &mut table_state);
let title = if app.log_lines.is_empty() { "empty".to_string() } else { format!("[{}/{}]", app.log.offset() + 1, app.log_lines.len()) }; let log = List::new(app.log_lines.iter().map(|line| ListItem::new(line.as_str()))) .block(Block::bordered().title(format!(" log {title} "))); let mut log_state = app.log; frame.render_stateful_widget(log, log_area, &mut log_state);
frame.render_widget( Paragraph::new(Line::from(format!( "draw {} · log {}@{}B · 100x12", app.draws, app.log_lines.len(), app.log_offset ))), 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()); }}┌ processes [1/14] ──────────────────┐┌ log [1/3] ─────────────────────────────────────────────────┐│PID COMMAND CPU% MEM%││2026-10-25T09:00:01Z INFO rlog starting, following app.log ││1 systemd 0.0 0.3││2026-10-25T09:00:01Z INFO tick 250ms, log poll 250ms, dirty││381 launchd 0.2 0.5││2026-10-25T09:00:02Z INFO first line appended while running││742 WindowServer 3.1 2.4││ ││1204 rlog 1.4 0.9││ ││1583 cargo 12.7 1.1││ ││1590 rustc 41.3 3.8││ ││1602 rustc 38.9 3.6││ ││1711 firefox 8.2 12.4││ │└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘draw 2 · log 3@190B · 100x12The pane is twelve rows here, so it shows eight of the fourteen processes; the real captures
below run at thirty rows and show them all. The status line is the one the real frame printed
after its first append — draw 2 · log 3@190B — minus the key and mouse fields, which read
(none yet) until something happens.
The loop
Section titled “The loop”run is where the pieces meet. The order inside the pass matters: the log is polled first, so
the first frame already shows the file as it stands; the draw comes second, gated on the flag;
the event read comes last and blocks for up to one tick. Events fold into the state through
apply, which returns false for the keys that end the program — quitting does not need
another frame. The whole file at this step:
//! rlog — a terminal UI that follows a log file and watches the process table.//!//! Step 5 of the track: the fixed demo data is gone. The log pane tails a real//! file — it remembers its offset, picks up whatever grew, and starts over when//! the file is truncated underneath it — and the loop runs on schedules: one//! tick for events, one for the file, and a dirty flag so the screen is redrawn//! only when something actually changed. The process table is still the demo//! rows of step 4; step 6 reads the real one.
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 ratatui::prelude::*;use ratatui::widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState};
/// 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. It matches the tick here, but it is its/// own constant because it is its own decision: 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. The process table of step 6 gets a slower schedule, because/// reading it costs real work.const LOG_POLL: Duration = Duration::from_millis(250);
/// 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";
/// The process table until step 6 reads the real one: pid, command, cpu/// percent, resident memory percent. Fourteen rows on purpose — more than the/// stacked layout can show at once, so the table has something to scroll.const PROCESSES: &[(&str, &str, f64, f64)] = &[ ("1", "systemd", 0.0, 0.3), ("381", "launchd", 0.2, 0.5), ("742", "WindowServer", 3.1, 2.4), ("1204", "rlog", 1.4, 0.9), ("1583", "cargo", 12.7, 1.1), ("1590", "rustc", 41.3, 3.8), ("1602", "rustc", 38.9, 3.6), ("1711", "firefox", 8.2, 12.4), ("1890", "Code Helper", 2.0, 6.1), ("2044", "node", 0.9, 4.2), ("2287", "tmux", 0.0, 0.2), ("2410", "zsh", 0.0, 0.1), ("2533", "ssh", 0.1, 0.2), ("2680", "top", 0.0, 0.1),];
/// The whole state of the program. Steps 6 and 7 grow it — the real process/// table, the selection, the history — and `draw` stays a pure function of it.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, /// The first log line on screen. `ListState` also carries a selection, /// which stays `None` until step 6. log: ListState, /// The first process row on screen. `TableState` likewise has no /// selection yet — step 6 adds one. table: TableState,}
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: ListState::default(), table: TableState::default(), }; 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. Three things happen each pass, in this order: the log file/// is polled if its interval has come around, the frame is drawn if anything is/// dirty, and then the loop waits 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<()> { // Due immediately, so the first frame already shows the file as it stands. let mut log_due = Instant::now();
loop { if Instant::now() >= log_due { poll_log(app)?; log_due = Instant::now() + LOG_POLL; }
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, and a resize reflows every pane. An event that/// changes nothing visible — a key release, say — leaves the flag alone.fn apply(app: &mut App, event: Event) -> bool { match event { Event::Key(key) => { if key.kind == KeyEventKind::Press { if is_quit(key) { return false; } 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 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, and // pull the scroll offset back with the emptied tail. app.log_offset = 0; app.log_lines.clear(); app.log_truncations += 1; clamp_log(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()); } if usable > 0 { app.dirty = true; } clamp_log(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);}
/// Pull the log's scroll offset back into range after its contents changed./// Truncating the file empties the tail, and a tail that shrank underneath a/// scrolled pane would otherwise leave the offset pointing past the end.fn clamp_log(app: &mut App) { let visible = log_visible(app.size); let last = app.log_lines.len().saturating_sub(visible); let offset = app.log.offset_mut(); if *offset > last { *offset = last; app.dirty = true; }}
/// Split the screen into the three panes: the process table, the log list and/// the one-row status line. Wide terminals put the two panes side by side;/// narrow ones stack them, the table above the log.////// The status line is `Length(1)`: it takes its row first, and the panes share/// what is left, so a squeezed pane can never push it off the screen.fn layout(area: Rect) -> [Rect; 3] { let [main, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); if area.width >= WIDE { let [table, log] = Layout::horizontal([Constraint::Percentage(38), Constraint::Fill(1)]).areas(main); [table, log, 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); [table, log, 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}
/// 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(), PROCESSES.len(), visible, delta); } else { let visible = log_area.height.saturating_sub(2) as usize; scroll_offset(app.log.offset_mut(), app.log_lines.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: three panes 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 the widget writes/// back while rendering lands on the copy, and the state changes only when an/// event says so.fn draw(frame: &mut Frame, app: &App) { let [table_area, log_area, status_area] = layout(frame.area());
let rows = PROCESSES.iter().map(|(pid, command, cpu, mem)| { Row::new(vec![ Cell::from(*pid), Cell::from(*command), 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(5), 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 [{}/{}] ", app.table.offset() + 1, PROCESSES.len() ))); let mut table_state = app.table; frame.render_stateful_widget(table, table_area, &mut table_state);
let log = List::new(app.log_lines.iter().map(|line| ListItem::new(line.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);
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 log pane's title: where the tail stands, and how many times it has been/// restarted. An empty tail says so rather than claiming a first line.fn log_title(app: &App) -> String { if app.log_lines.is_empty() { return "empty".to_string(); } let mut title = format!("[{}/{}]", app.log.offset() + 1, app.log_lines.len()); if app.log_truncations > 0 { title.push_str(&format!(" trunc {}", app.log_truncations)); } title}
/// 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_lines.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::*;
/// An app pointed at a scratch file, the state `main` builds at startup. fn app(log_path: PathBuf) -> 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: ListState::default(), table: TableState::default(), } }
/// 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")) }
#[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_lines.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_lines.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_lines.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_lines.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_lines.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_lines.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_lines.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_lines.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 27 of the 30 lines, so an offset of 2 is // legal and stays put. *app.log.offset_mut() = 2; app.dirty = false; clamp_log(&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(); clamp_log(&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.dirty = false; assert!(apply( &mut app, Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) )); assert!(app.dirty); assert_eq!(app.last_key.unwrap().code, KeyCode::Char('j'));
// 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_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}")); } 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 [table, log, status] = layout(Rect::new(0, 0, 100, 30)); assert_eq!(table, Rect::new(0, 0, 38, 29)); assert_eq!(log, Rect::new(38, 0, 62, 29)); assert_eq!(status, Rect::new(0, 29, 100, 1)); }
#[test] fn a_narrow_terminal_stacks_the_panes() { let [table, log, status] = layout(Rect::new(0, 0, 60, 24)); assert_eq!(table, Rect::new(0, 0, 60, 11)); assert_eq!(log, Rect::new(0, 11, 60, 12)); assert_eq!(status, Rect::new(0, 23, 60, 1)); }
#[test] fn a_pane_too_small_for_the_log_takes_nothing() { // Twelve rows leave the table its eleven after the status line takes // its one; the log's rect is empty, and rendering an empty rect draws // nothing rather than complaining. let [table, log, _] = layout(Rect::new(0, 0, 40, 12)); assert_eq!(table, Rect::new(0, 0, 40, 11)); assert_eq!(log, Rect::new(0, 11, 40, 0)); }
#[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")); for filled in [false, true] { app.log_lines.clear(); if filled { for i in 1..=30 { app.log_lines.push_back(format!("line {i}")); } } for (w, h) in [ (100, 30), (80, 24), (79, 24), (46, 18), (40, 12), (40, 11), (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}")); } 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, 0)].symbol()) .collect(); assert!(top.contains("log [1/30]"), "pane title was {top:?}");
// An empty tail says so instead of claiming a first line. app.log_lines.clear(); 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, 0)].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 below are the real ones, the same binary cargo run builds.
Running it
Section titled “Running it”printf '2026-10-25T09:00:01Z INFO rlog starting, following app.log\n' > app.logcargo run -- ./app.logWith no argument rlog follows ./app.log, so a bare cargo run works too. In another
terminal, echo 'another line' >> app.log appends, and printf 'fresh start\n' > app.log
truncates.
The first capture is a real run on a pseudo-terminal, 100 columns by 30 rows, with the log file written from outside while the program ran: one line appended, then two more, then the file truncated and refilled. The screen is the state after the truncation:
┌ processes [1/14] ──────────────────┐┌ log [1/2] trunc 1 ─────────────────────────────────────────┐│PID COMMAND CPU% MEM%││2026-10-25T09:00:10Z INFO file truncated and rewritten ││1 systemd 0.0 0.3││2026-10-25T09:00:10Z INFO the tail started over ││381 launchd 0.2 0.5││ ││742 WindowServer 3.1 2.4││ ││1204 rlog 1.4 0.9││ ││1583 cargo 12.7 1.1││ ││1590 rustc 41.3 3.8││ ││1602 rustc 38.9 3.6││ ││1711 firefox 8.2 12.4││ ││1890 Code Helper 2.0 6.1││ ││2044 node 0.9 4.2││ ││2287 tmux 0.0 0.2││ ││2410 zsh 0.0 0.1││ ││2533 ssh 0.1 0.2││ ││2680 top 0.0 0.1││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘draw 4 · log 2@105B · key (none yet) · mouse (none yet) · 100x30Four frames were drawn in the whole run: the first, one per batch of appended lines, and one for
the truncation. The pane title carries the restart count, and the status line carries the tail’s
stand — two lines, 105 bytes, which is exactly the length of the refilled file. The frame before
the truncation showed the five lines the appends had produced, at draw 3 · log 5@318B.
The same program started on a file that does not exist draws log missing and waits:
draw 1 · log missing · key (none yet) · mouse (none yet) · 100x30Creating the file draws the next frame with everything in it:
draw 2 · log 2@129B · key (none yet) · mouse (none yet) · 100x30The draw count depends on how the appends land against the 250 ms poll, so a reader’s own number
will differ; the shape will not. cargo test runs the fifteen tests — the four tail behaviours,
the offset clamp, the dirty flag, the three layout shapes and two render sweeps:
running 15 teststest tests::a_chunk_ending_in_a_newline_is_consumed_whole ... oktest tests::a_resize_reflows_and_dirties_the_frame ... oktest tests::a_keypress_leaves_the_frame_dirty_and_q_ends_the_loop ... oktest tests::the_tail_is_bounded_and_the_window_follows ... oktest tests::a_pane_too_small_for_the_log_takes_nothing ... oktest tests::a_wheel_notch_scrolls_the_log_pane ... oktest tests::a_shrunk_tail_pulls_the_offset_back ... oktest tests::a_missing_file_is_waited_for_not_an_error ... oktest tests::a_narrow_terminal_stacks_the_panes ... oktest tests::a_wide_terminal_puts_the_panes_side_by_side ... oktest tests::a_partial_line_waits_for_the_next_poll ... oktest tests::a_growing_file_is_read_from_where_the_tail_stopped ... oktest tests::a_truncated_file_starts_over ... oktest tests::the_log_pane_names_its_position ... oktest tests::the_frame_renders_at_every_size ... ok
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06sThe order of the lines varies from run to run; the set does not.
Step 6 reads the real process table with sysinfo and builds the interactive part: a selected
row, sorting by column, and the fuzzy filter over tui-input and nucleo. The
track overview keeps the full list current as articles land, and
step 4 is the layout this step’s data
lands in.