Skip to Content
ftui-widgetsComposition

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:

  1. Acquire the root Rect from frame.buffer.bounds().
  2. Split it into sub-rects using Layout, which is a pure function.
  3. Render a widget into each sub-rect via Widget::render or StatefulWidget::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:

  1. Render the normal content.
  2. Render the modal’s backdrop on top.
  3. 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 area before calling Layout::split, those writes get overwritten by whatever renders in the sub-rects that overlap. Always split first.
  • Forgetting to call StatefulWidget::render. StatefulWidget is not the same trait as Widget; you cannot call .render(area, frame) on a stateful one. The dispatch helper lives in ftui_render.
  • Recreating state on every frame. ListState::default() resets selection and scroll. Hold state on your model, not inside view.
  • 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 for VirtualizedList.

Where next