Skip to Content

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:

  1. A place to draw — the buffer.
  2. A way to turn complex text into cells — the grapheme pool.
  3. A way to register clickable regions — the hit grid.
  4. 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

crates/ftui-render/src/frame.rs
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

ConstructorWhen 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

crates/ftui-widgets/src/lib.rs
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). Prefer Widget when you can; reach for StatefulWidget only 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

examples/hello_widget.rs
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 frame

The 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 pool comes 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 to ui_height).

Where next