Widget composition
A FrankenTUI view() function is, at heart, a recipe for turning one Rect
into many smaller Rects and calling render(area, frame) on each. There is
no retained widget tree, no diffing at the widget level, and no hidden global
state. What you see on screen is exactly what you called render on, in the
order you called it.
This page walks through the mental model and builds a complete sidebar + main example, from the top of the frame down to the cell writes.
The mental model
A view is a pipeline of three things:
- Acquire the root
Rectfromframe.buffer.bounds(). - Split it into sub-rects using
Layout, which is a pure function. - Render a widget into each sub-rect via
Widget::renderorStatefulWidget::render.
There is no step four. Everything downstream (diff, ANSI, flush) is the runtime’s responsibility — see frame.
The Layout primitive
Layout is covered in depth on flex and grid; here we
only need the shape. Given a Rect and a list of Constraints, Layout
returns sub-rects:
use ftui_layout::{Layout, Direction, Constraint};
let [header, body, footer] = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // fixed 1-row header
Constraint::Min(5), // body takes everything else
Constraint::Length(1), // fixed 1-row footer
])
.split(area);Key properties:
- Pure — same inputs always produce the same rects.
- Deterministic — no allocation inside the solver’s hot path.
- Composable — you can split a sub-rect again, recursively.
Worked example: sidebar and main
A classic 2-pane layout: narrow sidebar with a list, wider main panel with a table. Selecting a row in the sidebar filters the table in the main.
Step 1 — pick your state
use ftui_widgets::list::ListState;
use ftui_widgets::table::TableState;
pub struct Model {
pub categories: Vec<String>,
pub entries: Vec<Entry>,
// Long-lived state owned by the model, not the widgets.
pub sidebar: ListState,
pub main: TableState,
}The model holds state because widgets are recreated each frame — you
never store a List or a Table between frames.
Step 2 — carve the rect
The snippets below use &mut self because StatefulWidget::render needs
&mut self.<state>. When plugging into a real Model::view(&self, frame),
wrap each stateful field in a RefCell (as in ftui-demo-showcase) and
call .borrow_mut() at the render site.
use ftui_layout::{Layout, Direction, Constraint};
fn render_workspace(&mut self, frame: &mut ftui_render::frame::Frame) {
let area = frame.buffer.bounds();
let [sidebar, main] = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(24), // 24-column sidebar
Constraint::Min(40), // main panel fills the rest
])
.split(area);
// … continued below
}Step 3 — render widgets into sub-rects
use ftui_widgets::{
Widget, StatefulWidget,
block::{Block, Borders},
list::{List, ListItem},
table::{Table, Row, Cell},
};
// Sidebar: bordered list of categories.
let categories: Vec<ListItem> =
self.categories.iter().map(|c| ListItem::new(c.as_str())).collect();
let list = List::new(categories)
.block(Block::default().borders(Borders::ALL).title(" Categories "))
.highlight_symbol("> ");
StatefulWidget::render(&list, sidebar, frame, &mut self.sidebar);
// Main: bordered table of entries, filtered by sidebar selection.
let visible = filter_entries(&self.entries, self.sidebar.selected);
let rows: Vec<Row> = visible.iter()
.map(|e| Row::new(vec![Cell::from(e.id), Cell::from(e.title.as_str())]))
.collect();
let table = Table::new(rows, &[Constraint::Length(8), Constraint::Min(20)])
.block(Block::default().borders(Borders::ALL).title(" Entries "));
StatefulWidget::render(&table, main, frame, &mut self.main);
}That’s the entire view.
What actually happened
During StatefulWidget::render:
List mutates its state
Before drawing, List::render clamps self.sidebar.offset so that
self.sidebar.selected stays in view. The caller’s state is mutated in
place — no clone.
List writes cells
It walks its items from offset, drawing one cell per visible row. The
Block wrapper draws borders and the title first.
Table does the same
Table computes column widths from the constraints, clamps its own scroll
state, and writes cells. Overlaps (borders touching) resolve
deterministically: later writes overwrite earlier writes within the same
frame.
Frame accumulates
Every write lands in frame.buffer. The buffer is diffed against the
previous frame, and only changed cells reach the terminal.
Nesting and recursion
You can split sub-rects as many times as you want. A typical three-pane editor layout:
let [header, body, footer] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(5), Constraint::Length(1)])
.split(area);
let [tree, editor, inspector] = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(24),
Constraint::Min(40),
Constraint::Length(36),
])
.split(body);Each leaf rect gets a widget. There is no widget that “knows it has
children” — the parent view() simply renders its children one after
another into the rects layout gave it.
Z-order: what wins when rectangles overlap
Inside one frame, later writes win. That is the entire Z-order model.
If you want a modal on top of normal content, you:
- Render the normal content.
- Render the modal’s backdrop on top.
- Render the modal’s body on top of that.
The modal stack widget does this for you. But the
raw primitive — “call render in the order you want” — is all there is. No
z_index, no retained tree, no compositor.
Degradation-aware composition
Inside degraded frames, decorative widgets are skipped. You compose as if
everything will render; the Budgeted<W> wrapper and the widget’s own
is_essential() decide what actually draws.
A chart on a dashboard:
let chart = Budgeted::new(widget_id!("chart"), Sparkline::new(&self.samples));
chart.render(area, frame);If the frame is running at EssentialOnly, the Sparkline (not essential
by default) will be skipped. The layout still happens — the rect was
allocated — but no cells are written. Your chart space simply stays whatever
was there last frame, or blank if nothing was.
Testing a view function
Because view() is a function from &mut Model (or &Model, for
stateless views) plus &mut Frame to buffer writes, you can test it the way
you test any other pure-ish function:
use ftui_render::{frame::Frame, Rect};
#[test]
fn view_renders_two_panes() {
let mut model = Model::sample();
let mut frame = Frame::new(Rect::new(0, 0, 80, 24));
model.view(&mut frame);
// Assert border characters on the expected column
let cell = frame.buffer.get(23, 0);
assert_eq!(cell.glyph(), '│'); // sidebar right border
}See snapshot tests for the ergonomic way to do
this (insta-backed golden files with BLESS=1).
Pitfalls
- Rendering before splitting. If you draw into
areabefore callingLayout::split, those writes get overwritten by whatever renders in the sub-rects that overlap. Always split first. - Forgetting to call
StatefulWidget::render.StatefulWidgetis not the same trait asWidget; you cannot call.render(area, frame)on a stateful one. The dispatch helper lives inftui_render. - Recreating state on every frame.
ListState::default()resets selection and scroll. Hold state on your model, not insideview. - Calling
view()re-entrantly. Widgets register cursor position and hit regions exactly once per frame; a re-entrant call will stomp them. - Building huge
Vec<ListItem>every frame. The allocation is not free. If you have 10K categories, reach forVirtualizedList.
Where next
How constraints resolve to rects, including Percentage, Ratio, and
Min / Max.
Tab order across panes, and how FocusManager threads through split
layouts.
The Frame object every render call receives.
Make your view() function regression-proof.
How this piece fits in widgets.
Widgets overview