Skip to content
Select theme

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:

Cargo.toml
[package]
name = "rlog"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.29"
ratatui = "0.30"

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:

8 passes · 4 polls · 4 draws · 3 lines

Eight 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.

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:

poll 1: offset 0 -> 11, 1 new line(s)
first line
poll 2: offset 11 -> 34, 2 new line(s)
second line
third line
poll 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:

held handle reads: "old contents\n"
fresh open reads: "new contents\n"

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.

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 bytes
idle for 3s: 2470 bytes

Nothing. 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.

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:

┌ 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 · 100x12

The 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.

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:

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.

Terminal window
printf '2026-10-25T09:00:01Z INFO rlog starting, following app.log\n' > app.log
cargo run -- ./app.log

With 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) · 100x30

Four 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) · 100x30

Creating the file draws the next frame with everything in it:

draw 2 · log 2@129B · key (none yet) · mouse (none yet) · 100x30

The 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 tests
test tests::a_chunk_ending_in_a_newline_is_consumed_whole ... ok
test tests::a_resize_reflows_and_dirties_the_frame ... ok
test tests::a_keypress_leaves_the_frame_dirty_and_q_ends_the_loop ... ok
test tests::the_tail_is_bounded_and_the_window_follows ... ok
test tests::a_pane_too_small_for_the_log_takes_nothing ... ok
test tests::a_wheel_notch_scrolls_the_log_pane ... ok
test tests::a_shrunk_tail_pulls_the_offset_back ... ok
test tests::a_missing_file_is_waited_for_not_an_error ... ok
test tests::a_narrow_terminal_stacks_the_panes ... ok
test tests::a_wide_terminal_puts_the_panes_side_by_side ... ok
test tests::a_partial_line_waits_for_the_next_poll ... ok
test tests::a_growing_file_is_read_from_where_the_tail_stopped ... ok
test tests::a_truncated_file_starts_over ... ok
test tests::the_log_pane_names_its_position ... ok
test 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.06s

The 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.