We continue with the series.
In the previous post the rat mapped the tunnels: dependencies, module structure, state machine, ownership strategy. All design, no pixels.
Now the rat picks up the brush. This post covers the View layer: the function that takes application state and paints it onto the terminal. In the TEA pattern, this is the pure rendering step: read &App, draw widgets, mutate nothing.
The View layer: ui.rs #
Ratatui uses immediate-mode rendering: every frame, you describe the entire screen from scratch. There is no retained widget tree, no virtual DOM, no diffing. You call terminal.draw(|frame| ...) and paint widgets onto rectangular areas. When the next frame comes, you paint again from zero.
This sounds wasteful. It is not. Ratatui and crossterm handle the terminal diffing internally, only sending the characters that actually changed. From our rat’s perspective, the mental model is simple: the UI is a pure function of the state. Whatever draw() paints is the truth, derived entirely from App.
The View layer is a single public function:
pub fn draw(app: &App, frame: &mut Frame) {
// Layout, table, command bar, status bar
}Note the signature: &App, not &mut App. The View reads state but never mutates it. This is the TEA contract: rendering is a projection, not a side effect.
Three-zone layout #
The screen is split into three vertical zones using Layout::vertical:
let chunks = Layout::vertical([
Constraint::Fill(1), // Main table: takes all remaining space
Constraint::Length(1), // Command bar: exactly 1 row
Constraint::Length(1), // Status bar: exactly 1 row
])
.split(frame.area());The table gets all the space it needs via Fill(1). The bottom two rows are fixed chrome: the command bar shows available shortcuts, and the status bar shows the active filter and task count. Clean and predictable. No matter how the terminal resizes, the proportions stay sensible.
The task table #
The table is the core of the interface. It uses render_stateful_widget with a TableState for row highlighting:
frame.render_stateful_widget(table, chunks[0], table_state);That table_state is the reason we need &mut TableState during rendering, even though we said the View does not mutate App. The distinction matters: TableState is rendering state (scroll position, selected row highlight), not application state (tasks, filter, mode). Ratatui’s StatefulWidget trait requires &mut State because it needs to update the scroll offset to keep the selected row visible. This is an implementation detail of the rendering layer, not a leak of mutation into the application model.
The columns are:
| Column | Width | Content |
|---|---|---|
| ID | 8 chars | UUID truncated to first 8 hex characters |
| Status | 10 chars | [ ] TODO or [x] DONE |
| Title | Fill remaining | Task title, gets all available space |
| Created | 12 chars | Mar 21 14:30 format |
| Modified | 12 chars | Mar 21 14:30 format |
The UUID truncation deserves a note. A full UUID v4 is 36 characters: a1b2c3d4-e5f6-7890-abcd-ef1234567890. That is screen real estate we cannot afford in a terminal. The first 8 hex characters (a1b2c3d4) give 4 billion combinations, more than enough uniqueness for a personal task list. The timestamps use chrono’s format("%b %d %H:%M"): compact, human-readable, and timezone-free (the user’s local context is implied).
The selected row is highlighted with ">> " as the highlight symbol. A small touch that makes keyboard navigation feel immediate: you see exactly where you are at all times.
The command bar: context-sensitive help #
The command bar is a Paragraph whose content changes based on InputMode:
fn render_command(app: &App) -> Line<'static> {
match app.input_mode {
InputMode::Normal => Line::from(vec![
Span::styled("[a]", Style::new().cyan()),
Span::raw("dd "),
Span::styled("[d]", Style::new().red()),
Span::raw("el "),
Span::styled("[x]", Style::new().yellow()),
Span::raw("done/todo "),
// ...
]),
InputMode::Adding => Line::from(vec![
Span::raw("New: "),
Span::raw(&app.input_buffer),
Span::styled("█", Style::new().cyan()),
Span::raw(" [Enter] confirm [Esc] cancel"),
]),
InputMode::ConfirmDelete => Line::from(vec![
Span::styled("Delete? ", Style::new().red().bold()),
Span::raw("[y]es [n]o"),
]),
}
}Each match arm returns a Line directly. This avoids a subtle lifetime problem: if you created a local String and returned a &str from it, the borrow would not outlive the function. By constructing Span::raw and Span::styled inline, everything is 'static and the lifetime checker is satisfied.
The command bar design follows a principle from the previous CLI series: discoverability. In a CLI, --help is the documentation. In a TUI, shortcuts must be visible without asking. The command bar changes with each mode, showing only the keys that are relevant right now. The user never has to guess.
The status bar #
let status = format!("Filter: {} | {} tasks", app.filter, app.tasks.len());Simple, informative, always visible. The filter label comes from TaskStatusFilter’s Display implementation, which means adding a new filter variant in the future automatically shows up here without touching ui.rs.
The Block gotcha #
One subtle detail cost me a few minutes: the table’s Block (the border with the “TODO Tasks” title) must be passed to the Table via .block(), not rendered separately:
// Correct: Block belongs to the Table
let table = Table::new(rows, widths)
.block(Block::bordered().title("TODO Tasks"))
.highlight_symbol(">> ");
// Wrong: rendering them independently makes them fight for the same area
frame.render_widget(block, area);
frame.render_widget(table, area); // ← overlaps the border
When you render a Block and a Table independently in the same Rect, the table content overwrites the border. The block must be owned by the table so ratatui calculates the inner area correctly, rendering the table content inside the border, not on top of it.
Where things stand #
The rat can paint. The table renders, the command bar adapts to the current mode, the status bar shows filter and count. But painting without ears is pointless: the TUI draws a beautiful screen that nobody can interact with.
In the next post, the rat wires up the ears: event handling, mode-specific key dispatch, the terminal restore safety net, and the migration moment of truth where cargo test tells us if the hexagonal promise held.
Reference code: