Widget and StatefulWidget traits
Every one of the 80+ widgets in ftui-widgets implements one of exactly two
traits. This page is the authoritative reference for their signatures,
semantics, and invariants. Where the overview explains
why the split exists, this page documents what you can rely on.
Widget — stateless render
Source: ftui-widgets/src/lib.rs:418
pub trait Widget {
/// Render the widget into the frame at the given area.
fn render(&self, area: Rect, frame: &mut Frame);
/// Whether this widget is essential and should always render.
///
/// Essential widgets render even at `EssentialOnly` degradation level.
/// Returns `false` by default, appropriate for decorative widgets.
fn is_essential(&self) -> bool { false }
}Two methods. One required, one defaulted.
renderreceives an immutable&selfand a mutableFrame. The widget cannot mutate its own fields during render — everything visible on screen must be computable from the fields set at construction time. If you need to mutate during render, you wantStatefulWidget.is_essentialreturnstruefor widgets the user cannot do without (inputs, primary content). The defaultfalseis correct for decorative widgets (borders, spinners, sparklines). See the degradation ladder for when this actually fires.
StatefulWidget — render with mutable state
Source: ftui-widgets/src/lib.rs:544
pub trait StatefulWidget {
/// The state type associated with this widget.
type State;
/// Render the widget into the frame, potentially modifying state.
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
}The state lives outside the widget. You own it, typically as a field on your model. The widget is still immutable; it just accepts a mutable reference to its partner state type.
State modifications during render should be bounded to: scroll offset
adjustments, selection clamping, and layout caching. Any other
mutation belongs in the runtime’s update() pass, not render.
What State actually holds
A typical state type carries three kinds of data:
- User-driven fields set by
update()— e.g.selected: Option<usize>. - Render-clamped fields the widget adjusts per frame — e.g.
offset: usize, to keep the selection visible. - Memoised layout fields — e.g. cached column widths, visible range.
The canonical example is ListState
(list.rs:377):
pub struct ListState {
pub selected: Option<usize>, // set by update
pub offset: usize, // clamped by render
// … plus cached layout fields
}What area: Rect gives you
Rect is the bounding box the widget must stay inside:
pub struct Rect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}Some invariants you can rely on:
- The rect is already clipped to the parent viewport — you do not need to intersect with screen bounds.
- Width or height may be zero; widgets should short-circuit gracefully.
Frame.bufferhas a scissor stack that enforces the rect at draw time. Writing outside it is a no-op, not undefined behaviour.- The rect is stable for the duration of your render call. Layout happens before render.
What frame: &mut Frame gives you
Frame is documented in detail on the frame reference
page. From a widget’s perspective, the useful subsystems are:
| Field | What it is | When to use |
|---|---|---|
frame.buffer | The cell grid you draw into | Every widget touches this |
frame.set_cursor(Some((x, y))) | Logical cursor placement (use the setter, not the field) | Input widgets (TextInput, Textarea) |
frame.hit_grid | Mouse/pointer hit regions | Any widget that should respond to clicks |
frame.degradation | Current performance tier | Widgets that want to render differently under pressure |
frame.register_link(url) | Intern a URL, get a stable link_id | Paragraph, help, status line |
frame.register_widget_signal(sig) | Tell the budget system you exist | Used by Budgeted<W> wrapper |
You do not emit ANSI from a widget. You do not call flush. You do
not touch stdout. The presenter does that downstream, once per frame,
for the whole buffer.
The render lifecycle
A single widget’s render call sits inside a well-defined sequence:
Layout splits the parent rect
The parent widget (or your view() function) calls
Layout::default().constraints(...).split(area) to carve the screen into
sub-rects. Layout is a pure function.
render(area, frame) is called
Your widget writes cells into frame.buffer, possibly registers hit
regions, cursor position, or link IDs. For StatefulWidget, the widget may
also mutate state.
Frame accumulates writes
All widgets in the view write to the same frame.buffer. Order matters —
later writes overwrite earlier ones. This is how overlays (modals, toasts)
sit on top of underlying content.
BufferDiff computes the minimal delta
Once view() returns, the runtime diffs the new buffer against the previous
one. Widgets are already done by this point.
Presenter emits ANSI
A single writer serializes the diff to stdout. No widget code runs here.
Calling a StatefulWidget from view()
Stateful widgets are rendered by calling StatefulWidget::render directly.
There is no free-function wrapper — you are invoking the trait method:
use std::cell::RefCell;
use ftui_widgets::list::{List, ListState};
use ftui_widgets::StatefulWidget;
struct MyModel {
items: Vec<String>,
list_state: RefCell<ListState>, // interior-mut so `view(&self, ..)` can borrow
}
fn view(&self, frame: &mut Frame) {
let area = frame.buffer.bounds();
let list = List::new(&self.items).highlight_symbol("> ");
let mut state = self.list_state.borrow_mut();
StatefulWidget::render(&list, area, frame, &mut *state);
}Note that view takes &self (the Model trait requires it). Because
StatefulWidget::render needs &mut State, the model wraps the state in
a RefCell (or Cell for Copy state). That split is intentional —
the runtime owns long-lived state, not the widget.
Budgeted<W> — opting into signal tracking
Source: ftui-widgets/src/lib.rs:442
Wrap a widget with Budgeted::new(widget_id, inner) to register a
WidgetSignal with the frame budget system each render. If the budget is
exhausted and the widget is non-essential, Budgeted will skip the
inner render — the wrapper is the enforcement point for is_essential
during degradation.
let wrapped = Budgeted::new(0xB10C, Block::default().borders(Borders::ALL));
wrapped.render(area, frame);You can usually ignore this wrapper — high-level APIs apply it for you.
Pitfalls
- Mutating
selfinsideWidget::render. The trait takes&self, not&mut self. If you need mutation, switch toStatefulWidget. If that feels wrong, the mutation probably belongs inupdate(). - Cloning large state on every frame.
StatefulWidget::rendertakes&mut Stateprecisely so you can mutate in place. Cloning aListStateon every frame is a common performance footgun. - Assuming
areahas at least 1x1. Always guardarea.width == 0 || area.height == 0early if your widget’s math would underflow. - Expecting render order inside a Frame to be stable under re-layout. It is stable within one frame but may shift frame-to-frame if your view code reorders children.