Skip to Content
ftui-widgetsWidget traits

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.

  • render receives an immutable &self and a mutable Frame. 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 want StatefulWidget.
  • is_essential returns true for widgets the user cannot do without (inputs, primary content). The default false is 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:

  1. User-driven fields set by update() — e.g. selected: Option<usize>.
  2. Render-clamped fields the widget adjusts per frame — e.g. offset: usize, to keep the selection visible.
  3. 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.buffer has 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:

FieldWhat it isWhen to use
frame.bufferThe cell grid you draw intoEvery widget touches this
frame.set_cursor(Some((x, y)))Logical cursor placement (use the setter, not the field)Input widgets (TextInput, Textarea)
frame.hit_gridMouse/pointer hit regionsAny widget that should respond to clicks
frame.degradationCurrent performance tierWidgets that want to render differently under pressure
frame.register_link(url)Intern a URL, get a stable link_idParagraph, help, status line
frame.register_widget_signal(sig)Tell the budget system you existUsed 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 self inside Widget::render. The trait takes &self, not &mut self. If you need mutation, switch to StatefulWidget. If that feels wrong, the mutation probably belongs in update().
  • Cloning large state on every frame. StatefulWidget::render takes &mut State precisely so you can mutate in place. Cloning a ListState on every frame is a common performance footgun.
  • Assuming area has at least 1x1. Always guard area.width == 0 || area.height == 0 early 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.

Where next