Frame API
Frame is the object the runtime hands your widget tree in
Model::view(). It bundles a Buffer, a
borrowed GraphemePool, optional cursor
position, an optional hit grid for mouse interaction, and the
widget-budget policy for the current frame. Widgets render into it
by calling Widget::render(&self, area, frame); downstream, the
runtime turns the mutated buffer into ANSI via the
presenter.
Unlike a classic Rust “builder”, Frame is a short-lived,
single-use render target with a lifetime tied to the grapheme pool
it borrows. It’s created each frame, filled, presented, and dropped.
Widgets receive &mut Frame; they may call frame.buffer,
frame.pool, frame.register_link, frame.set_cursor, and so on.
This page documents the constructors, the widget contract, and the escape hatches (arena allocations, hit regions, degradation) that widgets use to render efficiently and correctly.
Motivation
Widgets need four things from every render pass:
- A place to draw — the buffer.
- A way to turn complex text into cells — the grapheme pool.
- A way to register clickable regions — the hit grid.
- A way to signal where the cursor should end up — the cursor position.
Bundling these into a Frame means the widget signature is a flat
fn render(&self, area: Rect, frame: &mut Frame). No builders, no
context objects that accumulate over nested composition. A table
widget rendering a row drills down with frame.buffer.set(...) and
frame.register_link(url) directly; it does not need a “render
context” parameter.
Struct layout
pub struct Frame<'a> {
pub buffer: Buffer, // the cell grid
pub pool: &'a mut GraphemePool, // intern widely-used clusters
pub links: Option<&'a mut LinkRegistry>, // OSC 8 URLs
pub hit_grid: Option<HitGrid>, // clickable regions
hit_owner_stack: Vec<HitOwner>,
pub widget_budget: WidgetBudget,
pub widget_signals: Vec<WidgetSignal>,
pub cursor_position: Option<(u16, u16)>,
pub cursor_visible: bool,
pub degradation: DegradationLevel,
pub arena: Option<&'a FrameArena>, // per-frame bump arena
}Every field is documented and public (or with a public accessor).
The lifetime 'a ties the frame to the pool it borrows: the frame
cannot outlive the render pass, which is correct by construction.
Constructors
| Constructor | When to use |
|---|---|
Frame::new(width, height, &mut pool) | Basic render, no hit testing or links. |
Frame::from_buffer(buffer, &mut pool) | Reuse a persistent buffer (runtime fast path). |
Frame::with_links(w, h, &mut pool, &mut links) | Hyperlinks needed. |
Frame::with_hit_grid(w, h, &mut pool) | Mouse hit testing needed. |
The runtime typically calls from_buffer to avoid per-frame buffer
allocation: it keeps two Buffers for double buffering and swaps
them after each present.
Widget contract
pub trait Widget {
fn render(&self, area: Rect, frame: &mut Frame);
/// Whether this widget is essential and should always render.
/// Essential widgets render even at EssentialOnly degradation.
fn is_essential(&self) -> bool { false }
}
pub trait StatefulWidget {
type State;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
}Widget— stateless render. Most widgets (panels, borders, text, buttons, static tables) implement this.StatefulWidget— the render pass may mutate state (scroll offset clamp, selection recompute, layout cache). PreferWidgetwhen you can; reach forStatefulWidgetonly when the render pass genuinely needs to write back.
area: Rect is the bounding rectangle the parent assigned. Widgets
are expected to stay inside it; the buffer’s scissor stack
(cell-and-buffer)
enforces this, but well-behaved widgets clip on their own to avoid
drawing work that will be discarded.
Minimal widget
use ftui_core::geometry::Rect;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_widgets::Widget;
pub struct Hello;
impl Widget for Hello {
fn render(&self, area: Rect, frame: &mut Frame) {
let text = "Hello, world!";
for (i, ch) in text.chars().enumerate() {
let x = area.x + i as u16;
if x >= area.x + area.width { break; } // stay in bounds
frame.buffer.set(x, area.y, Cell::from_char(ch));
}
}
}Stateful widget
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_widgets::StatefulWidget;
pub struct List<'a> { items: &'a [&'a str] }
pub struct ListState { pub selected: Option<usize>, pub offset: usize }
impl<'a> StatefulWidget for List<'a> {
type State = ListState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
// Clamp offset so selection stays visible.
if let Some(sel) = state.selected {
if sel < state.offset { state.offset = sel; }
if sel >= state.offset + area.height as usize {
state.offset = sel + 1 - area.height as usize;
}
}
// Draw visible slice starting at state.offset...
}
}Dimensions & bounds
frame.width() // u16 — same as frame.buffer.width()
frame.height() // u16 — same as frame.buffer.height()
frame.bounds() // Rect { x: 0, y: 0, width, height }There is no frame.area() — bounds() is the idiomatic call.
Individual widgets receive their own sub-Rect via the area
argument passed by the parent layout.
Cursor
frame.set_cursor(Some((14, 3))); // show cursor at (x=14, y=3)
frame.set_cursor_visible(false); // hide cursor this frameThe runtime translates cursor_position + cursor_visible into
final ANSI (CSI ?25h / ?25l + CUP) after the presenter emits
cells. If multiple widgets compete for the cursor, last writer
wins — typically the focused input.
Hit regions
When the frame was built with with_hit_grid, widgets can register
clickable regions:
use ftui_render::frame::{HitId, HitRegion};
frame.register_hit(
HitId(42),
area, // Rect covered by this widget
HitRegion::Button,
/* opaque data */ 0u8,
);The runtime intersects mouse events against the hit grid and
dispatches SemanticEvent::Click { .. } to the correct widget
based on the registered HitId. An owner stack
(hit_owner_stack) lets nested widgets scope ownership — a button
inside a modal inside a panel lands on the button, not the panel.
Degradation and widget budgets
if frame.degradation == DegradationLevel::EssentialOnly {
// Skip decorative rendering; trust the parent to show the
// minimum the user needs.
}
if !frame.should_render_widget(self.widget_id, self.is_essential()) {
return; // budget denied this widget for this frame
}DegradationLevel is set by the runtime based on the frame budget
(how many milliseconds are left in the current tick). The built-in
Budgeted<W> wrapper in ftui-widgets calls
should_render_widget for you; hand-rolled widgets can do the same
to cooperate with the scheduler.
Per-frame arena
if let Some(arena) = frame.arena() {
let scratch = arena.alloc_str(&format!("temp {}", n));
// pass `scratch` into a widget (e.g. Paragraph::new(scratch)) —
// no heap alloc, arena is reset at end of frame
}The runtime can provide a bump arena per frame; widgets use it for formatted strings, intermediate slices, anything that should evaporate at the end of the render pass. This matters on the hot path where the default allocator’s per-call overhead would compound across hundreds of widgets.
Never hold onto frame.pool or frame.buffer references beyond
render(). The frame’s lifetime ends at the present; any
reference into it dangles. State in a StatefulWidget must hold
owned data (clones, indices, offsets), not borrows into the frame.
Cross-references
- Cell & Buffer — the grid the frame wraps.
- Grapheme pool — where
poolcomes from and how to intern. - Presenter — what consumes the mutated buffer.
- Model trait — where the runtime calls
view(frame)in the Elm loop. - Screen modes — inline vs. alt-screen affects
what
frame.bounds()returns (inline restricts toui_height).