Skip to content
Select theme

Laying out panels and tables

Step 4 of 8. Step 3 left a loop that reads every kind of event and a frame that reports the last of each back — inside one bordered box filling the screen. This step turns that box into panes: a process table, a log list and a one-row status line, laid out by constraints that answer the terminal’s size. The wheel events step 3 reads now scroll whichever pane the pointer is over. Start from tag tui-ch03; the end state is tagged tui-ch04.

The dependencies are unchanged. Ratatui draws, crossterm reads the terminal, and the layout, the Table and the List all come from ratatui itself:

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

draw no longer renders one widget into frame.area(). It asks a layout function for three rects — the table, the log, the status line — and renders a widget into each. That function is the whole of the responsiveness, and it is small enough to print in full:

use ratatui::layout::{Constraint, Layout, Position, Rect};
/// At or above this width the panes sit side by side; below it they stack.
const WIDE: u16 = 80;
/// The same split `rlog`'s `draw` makes: the process table, the log list and
/// the one-row status line, in that order.
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() {
for (w, h) in [
(100, 30),
(80, 24),
(79, 24),
(60, 24),
(46, 18),
(40, 12),
(20, 6),
] {
let [table, log, status] = layout(Rect::new(0, 0, w, h));
println!(
"{w:>3}x{h:<2} table {:>2}x{:<2}@({:>2},{:>2}) log {:>2}x{:<2}@({:>2},{:>2}) status {:>2}x1",
table.width, table.height, table.x, table.y,
log.width, log.height, log.x, log.y,
status.width,
);
}
// The rects are also the hit map: a wheel notch asks which pane the
// pointer is over, and the answer comes from the same split.
let [table, log, status] = layout(Rect::new(0, 0, 60, 24));
for (col, row) in [(5, 5), (30, 20), (30, 23)] {
let at = Position::new(col, row);
let pane = if table.contains(at) {
"table"
} else if log.contains(at) {
"log"
} else {
"status"
};
println!("({col},{row}) is in the {pane} pane");
}
}
100x30 table 38x29@( 0, 0) log 62x29@(38, 0) status 100x1
80x24 table 30x23@( 0, 0) log 50x23@(30, 0) status 80x1
79x24 table 79x11@( 0, 0) log 79x12@( 0,11) status 79x1
60x24 table 60x11@( 0, 0) log 60x12@( 0,11) status 60x1
46x18 table 46x11@( 0, 0) log 46x6 @( 0,11) status 46x1
40x12 table 40x11@( 0, 0) log 40x0 @( 0,11) status 40x1
20x6 table 20x5 @( 0, 0) log 20x0 @( 0, 5) status 20x1
(5,5) is in the table pane
(30,20) is in the log pane
(30,23) is in the status pane

Four things to read out of that. The status line is Length(1) — an exact size, claimed before anything else, so the panes share what remains and can never push it off the screen. The wide split is Percentage(38) and Fill(1): the table takes 38% of the width — 38 columns at 100, 30 at 80 — and the log takes the rest. The narrow split is Length(11) and Fill(1): the table is exactly eleven rows — header, eight processes and the border — whenever there are eleven to give. The boundary is area.width >= WIDE with WIDE = 80, so a terminal opens stacked at its default width and goes side by side the moment it is dragged to 80 columns.

The last two rows are the small sizes, and they are where the layout earns its keep. At 40x12 the status line takes its row, the table takes the eleven that remain, and the log’s rect is empty — height zero. Rendering an empty rect draws nothing; there is no panic and no complaint. At 20x6 even the table cannot have its eleven, so it takes all five rows the main area has and the log gets nothing. One test renders the frame at ten sizes, from 100x30 down to 0x0, to keep that true.

Why Length(11) for the table and not Min(11)? A Min is a floor, and the solver does not stop at a floor when there is slack. With [Min(11), Fill(1)] over 23 rows the table gets 12 and the log 11: the default flex shares the excess between the two instead of leaving it to the log. That is the wrong shape here — the table’s height should not grow because the terminal got taller, and the log wants the room. Length is a promise, Fill is “whatever is left”, Percentage is a share of the parent; those three cover most layouts.

The process table is a Table: a header Row, one Row per process, and a width constraint per column. The construction, with three of the fourteen rows the project carries, rendered into ratatui’s in-memory terminal:

use ratatui::backend::TestBackend;
use ratatui::layout::Constraint;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Cell, Row, Table};
use ratatui::Terminal;
fn main() {
let processes = [
("1204", "rlog", 1.4, 0.9),
("1583", "cargo", 12.7, 1.1),
("1590", "rustc", 41.3, 3.8),
];
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(" processes [1/3] "));
let mut terminal = Terminal::new(TestBackend::new(46, 6)).unwrap();
terminal
.draw(|frame| frame.render_widget(table, frame.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/3] ───────────────────────────┐
│PID COMMAND CPU% MEM%│
│1204 rlog 1.4 0.9│
│1583 cargo 12.7 1.1│
│1590 rustc 41.3 3.8│
└────────────────────────────────────────────┘

The widths are the same four constraints in every layout: Length(5) pins the pid and the two percentage columns, and Min(8) lets the command column take the slack — the table reserves one column of spacing between each pair, so at the 36 inner columns of the wide layout the command column is 18 wide and grows with the pane. The two numeric columns are right-aligned — a Cell renders whatever Text it is given, and a Line carries its own alignment — so the decimal points line up down the table however the values change.

The log list is a List of ListItems with a bordered block, and it needs no width constraints: one line per row, clipped at the pane’s edge. Both widgets render through render_stateful_widget, which takes the widget’s state — TableState and ListState — by mutable reference, and both states live in App beside the frame counter and the last events.

The states start at their defaults: offset zero, no selection. Selection arrives in step 6; this step only scrolls. Each pane’s title carries its scroll position — processes [4/14], log [4/30] — because a pane that has scrolled and does not say so is a pane the reader cannot check.

One detail in the project’s draw is worth a sentence: the state it renders from is a copy of the one in App. Both state types are Copy, and the copy is what the widget writes its clamped offset back into while rendering. draw therefore takes the app by shared reference, and the state changes only when an event says so — the frame is a function of the state, nothing else.

A bordered widget spends its outermost row and column on the border, and the table spends its first inner row on the header. That is why the table’s visible rows are height - 3 and the log’s are height - 2, and it is why a line longer than the pane is clipped rather than wrapped: List draws one line per row and lets the pane decide where the text ends.

The wheel scrolls the pane under the pointer

Section titled “The wheel scrolls the pane under the pointer”

Step 3’s loop keeps the last mouse event; this step acts on the wheel. One notch moves the scroll offset by SCROLL_STEP lines — three, enough to traverse a long log without paging it — and the pane it moves is the one the pointer is over. The hit test is table_area.contains(at) with at = Position::new(mouse.column, mouse.row), and the last three lines of the block above are it running: at 60x24, column 5 row 5 is in the table, column 30 row 20 is in the log, and row 23 is the status line, where nothing scrolls.

The rects come from the same layout function the frame renders into, so the two can never disagree about where a pane is: a resize moves both together. The visible-row counts come from the same rects too — the log can show height - 2 lines because the border takes a row, the table height - 3 because the border and the header each take one — which is what makes the clamp exact rather than approximate.

The clamp is the interesting part, and it is the one piece of logic here worth a unit test:

use ratatui::backend::TestBackend;
use ratatui::widgets::{Block, List, ListItem, ListState};
use ratatui::Terminal;
/// The same clamp `rlog` uses: never above zero, never past `len - visible`.
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;
}
fn main() {
// Thirty lines in a pane that shows six of them.
let lines: Vec<String> = (1..=30).map(|i| format!("line {i}")).collect();
let area = ratatui::layout::Rect::new(0, 0, 46, 8);
let visible = (area.height - 2) as usize; // the border takes a row
let mut state = ListState::default();
let mut terminal = Terminal::new(TestBackend::new(46, 8)).unwrap();
// Down a notch, back up past the top, then down past the end: the clamp is
// what the edges demonstrate.
for delta in [3, -3, -3, 3, 3, 3, 3, 3, 3, 3, 3, 3] {
scroll_offset(state.offset_mut(), lines.len(), visible, delta);
println!(
"notch {delta:+} -> offset {} (first line {:?})",
state.offset(),
lines[state.offset()]
);
}
// The widget agrees with the offset: render it and print the window.
let list = List::new(lines.iter().map(|line| ListItem::new(line.as_str())))
.block(Block::bordered().title(" log "));
terminal
.draw(|frame| frame.render_stateful_widget(list, area, &mut state))
.unwrap();
let buffer = terminal.backend().buffer();
for y in 1..area.height as usize - 1 {
let row: String = (0..buffer.area.width)
.map(|x| buffer[(x as u16, y as u16)].symbol())
.collect();
println!("{}", row.trim_end());
}
}
notch +3 -> offset 3 (first line "line 4")
notch -3 -> offset 0 (first line "line 1")
notch -3 -> offset 0 (first line "line 1")
notch +3 -> offset 3 (first line "line 4")
notch +3 -> offset 6 (first line "line 7")
notch +3 -> offset 9 (first line "line 10")
notch +3 -> offset 12 (first line "line 13")
notch +3 -> offset 15 (first line "line 16")
notch +3 -> offset 18 (first line "line 19")
notch +3 -> offset 21 (first line "line 22")
notch +3 -> offset 24 (first line "line 25")
notch +3 -> offset 24 (first line "line 25")
│line 25 │
│line 26 │
│line 27 │
│line 28 │
│line 29 │
│line 30 │

The widget agrees with the offset: the window rendered at the end is the one the state names. Two edges are worth naming. Scrolling up stops at zero — there is nothing above the first line to reveal. Scrolling down stops with the last line on screen, not with the last line at the top: an offset of len - visible puts the final line at the bottom of the pane. And when the pane is taller than its content — after a resize, or a short log — len - visible saturates to zero, so a notch pulls a stale offset back instead of leaving blank space above the first line.

The event arm grew three lines: the mouse event is stored as before, and a wheel notch then moves one of the two offsets. 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
cargo run

The first capture is a real run on a pseudo-terminal, 100 columns by 30 rows, with two wheel-downs over the log pane and one over the table pane written into its input. The frame count depends on how long the program ran before the keys landed, so a reader’s own number will differ.

┌ processes [1/14] ──────────────────┐┌ log [4/30] ────────────────────────────────────────────────┐
│PID COMMAND CPU% MEM%││2026-10-24T21:04:02Z DEBUG Compiling rlog v0.1.0 │
│1 systemd 0.0 0.3││2026-10-24T21:04:03Z DEBUG Compiling ratatui v0.30.2 │
│381 launchd 0.2 0.5││2026-10-24T21:04:04Z DEBUG Compiling crossterm v0.29.0 │
│742 WindowServer 3.1 2.4││2026-10-24T21:04:06Z WARN unused import: `std::fmt` │
│1204 rlog 1.4 0.9││2026-10-24T21:04:06Z INFO Finished dev profile in 4.11s │
│1583 cargo 12.7 1.1││2026-10-24T21:04:06Z INFO Running target/debug/rlog │
│1590 rustc 41.3 3.8││2026-10-24T21:05:31Z INFO terminal 100x30, panes side by si│
│1602 rustc 38.9 3.6││2026-10-24T21:05:44Z INFO wheel over log at 60,10 -> offset│
│1711 firefox 8.2 12.4││2026-10-24T21:05:58Z INFO resize 60x24, panes stacked │
│1890 Code Helper 2.0 6.1││2026-10-24T21:06:12Z WARN log line wider than the pane is c│
│2044 node 0.9 4.2││2026-10-24T21:06:30Z ERROR failed to open ./app.log, retryin│
│2287 tmux 0.0 0.2││2026-10-24T21:06:31Z INFO ./app.log opened at offset 0 │
│2410 zsh 0.0 0.1││2026-10-24T21:07:02Z INFO watching 3 log files, 0 errors │
│2533 ssh 0.1 0.2││2026-10-24T21:07:44Z DEBUG process table refreshed, 14 row│
│2680 top 0.0 0.1││2026-10-24T21:08:19Z INFO cpu 12.7% cargo, 41.3% rustc │
│ ││2026-10-24T21:08:51Z WARN rustc idle for 32s │
│ ││2026-10-24T21:09:26Z INFO build finished, 0 warnings │
│ ││2026-10-24T21:10:03Z INFO rlog idle, no new lines │
│ ││2026-10-24T21:10:40Z DEBUG tick 250ms, 0 events │
│ ││2026-10-24T21:11:15Z INFO terminal 100x30, panes side by si│
│ ││2026-10-24T21:11:52Z INFO wheel over table at 5,5 -> offset│
│ ││2026-10-24T21:12:30Z WARN table scrolled past the last row │
│ ││2026-10-24T21:13:07Z INFO log line 30 of 30 shown │
│ ││2026-10-24T21:13:44Z DEBUG frame 328, dirty false │
│ ││2026-10-24T21:14:21Z INFO resize 46x18, panes stacked │
│ ││2026-10-24T21:14:58Z INFO rlog stopping on q │
│ ││2026-10-24T21:14:58Z INFO rlog stopped, terminal restored │
└────────────────────────────────────┘└────────────────────────────────────────────────────────────┘
frame 14 · key (none yet) · mouse scroll down at 4,4 · 100x30

Three things to check. The panes are side by side, the table 38 columns wide. The log has scrolled: two notches ask for six lines, but a 30-line log in a 27-row pane can only scroll three, so the offset clamped at 3 and the title reads log [4/30]. And the table did not move — its wheel notch landed on a pane where all fourteen rows are already visible, which is the honest answer at this size.

The second capture is the same program after a resize to 60x24, with one wheel notch over the table pane:

┌ processes [4/14] ────────────────────────────────────────┐
│PID COMMAND CPU% MEM%│
│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│
└──────────────────────────────────────────────────────────┘
┌ log [4/30] ──────────────────────────────────────────────┐
│2026-10-24T21:04:02Z DEBUG Compiling rlog v0.1.0 │
│2026-10-24T21:04:03Z DEBUG Compiling ratatui v0.30.2 │
│2026-10-24T21:04:04Z DEBUG Compiling crossterm v0.29.0 │
│2026-10-24T21:04:06Z WARN unused import: `std::fmt` │
│2026-10-24T21:04:06Z INFO Finished dev profile in 4.11s│
│2026-10-24T21:04:06Z INFO Running target/debug/rlog │
│2026-10-24T21:05:31Z INFO terminal 100x30, panes side by │
│2026-10-24T21:05:44Z INFO wheel over log at 60,10 -> offs│
│2026-10-24T21:05:58Z INFO resize 60x24, panes stacked │
│2026-10-24T21:06:12Z WARN log line wider than the pane is│
└──────────────────────────────────────────────────────────┘
frame 16 · key (none yet) · mouse scroll down at 4,4 · 60x24

The panes restacked: the table on top at its eleven rows, the log below it, the status line still at the bottom. The log kept its offset of 3 through the reflow — a resize does not scroll anything — while the table’s notch moved it to processes [4/14], because at twelve rows the table shows eight of its fourteen and there is something to scroll. The log’s long lines are clipped at the pane’s right edge rather than wrapped: panes side by si is the whole line at this width.

cargo test runs the eight tests in the file — the four scroll clamps, the two layout shapes, the empty-pane case, and a sweep that renders the frame at ten sizes:

running 8 tests
test tests::a_notch_moves_the_offset_by_the_step ... ok
test tests::a_pane_taller_than_the_content_pulls_the_offset_back ... ok
test tests::scrolling_down_stops_with_the_last_line_on_screen ... ok
test tests::scrolling_up_stops_at_the_top ... ok
test tests::a_pane_too_small_for_the_log_takes_nothing ... ok
test tests::a_narrow_terminal_stacks_the_panes ... ok
test tests::a_wide_terminal_puts_the_panes_side_by_side ... ok
test tests::the_frame_renders_at_every_size ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

Step 5 fills both panes with live data: the log tail follows a file and the table reads the process list, on a refresh schedule that does not redraw when nothing has changed. The track overview keeps the full list current as articles land.