Skip to Content
ftui-runtimeModel trait

The Model trait

Every FrankenTUI application implements a single trait. Model is the Elm triple — state, transition, render — lifted into Rust with two extra hooks (on_shutdown, on_error) and one declarative projection (subscriptions).

File: crates/ftui-runtime/src/program.rs:123.

Signature

crates/ftui-runtime/src/program.rs
pub trait Model: Sized { type Message: From<Event> + Send + 'static; fn init(&mut self) -> Cmd<Self::Message> { Cmd::none() } fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>; fn view(&self, frame: &mut Frame); fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> { vec![] } fn on_shutdown(&mut self) -> Cmd<Self::Message> { Cmd::none() } fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> { Cmd::none() } fn as_screen_tick_dispatch( &mut self, ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> { None } }

Only update and view are required. Everything else has a safe default.

The Message: From<Event> contract

type Message: From<Event> + Send + 'static;

This is the most important line in the trait. It says every terminal event has a canonical projection into your message type. The runtime uses it to convert Event::Key, Event::Resize, Event::Mouse, Event::Focus, Event::Paste, and Event::Tick into values update can pattern-match.

The conversion is total: even events you don’t care about must map to something. A common pattern is a catch-all variant that your update ignores:

enum Msg { Key(KeyEvent), Resize(u16, u16), Ignore } impl From<Event> for Msg { fn from(e: Event) -> Self { match e { Event::Key(k) => Msg::Key(k), Event::Resize { width, height } => Msg::Resize(width, height), _ => Msg::Ignore, } } }

The Send + 'static bound lets the runtime move messages across thread boundaries when a subscription’s background thread sends one, or when a completed Cmd::Task returns its result into the update queue.

Method-by-method reference

init

fn init(&mut self) -> Cmd<Self::Message> { Cmd::none() }

Called once, before the main loop enters. Return a Cmd to kick off any work that has to happen before the first frame is painted — typically Cmd::task to load configuration, or Cmd::restore_state to hydrate persisted widget state. The returned command is executed before view runs for the first time.

update

fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;

The only place state mutates. Every input event, every subscription message, and every completed task delivers its result through this method. Return a Cmd (possibly Cmd::none) to schedule follow-on effects.

Do not block inside update. A blocking call freezes input handling, subscription draining, and the render loop at the same time. If you need to call something slow, wrap it in Cmd::task.

view

fn view(&self, frame: &mut Frame);

Pure rendering. view takes &self, so the compiler guarantees it cannot mutate model state. Use the Frame API to draw; widgets you compose read from the frame’s size and write to its cell buffer. The runtime calls view only when the frame is dirty or an explicit repaint is required.

subscriptions

fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> { vec![] }

Declarative. Return the set of subscriptions that should be running right now, not the set to start. The runtime diffs this list against the previous cycle’s by SubId and starts/stops threads accordingly. See subscriptions for the reconciliation algorithm.

on_shutdown

fn on_shutdown(&mut self) -> Cmd<Self::Message> { Cmd::none() }

Called after Cmd::quit or a terminal signal, before the runtime tears down. Return any final commands (for example Cmd::save_state to flush pending state to disk). The runtime waits for those commands to drain before exiting.

on_error

fn on_error(&mut self, error: &str) -> Cmd<Self::Message> { Cmd::none() }

Called when a subscription thread panics or an effect returns an error the runtime caught. You cannot recover the subscription from here (it is already gone), but you can ask the runtime to show a toast, log to the evidence sink, or quit gracefully.

as_screen_tick_dispatch

Optional. If your model implements the ScreenTickDispatch trait (because you manage multiple screens with independent tick rates), the runtime uses it to consult the tick strategy instead of ticking every screen every frame. Most applications leave this at None.

A fully worked model

examples/search_box.rs
use ftui::prelude::*; use ftui_core::event::KeyCode; use ftui_core::geometry::Rect; use ftui_runtime::subscription::Every; use ftui_widgets::paragraph::Paragraph; use std::time::Duration; #[derive(Clone, Debug)] enum Msg { Key(KeyEvent), Resize(u16, u16), Tick, SearchDone(Vec<String>), Ignore, } impl From<Event> for Msg { fn from(e: Event) -> Self { match e { Event::Key(k) => Msg::Key(k), Event::Resize { width, height } => Msg::Resize(width, height), Event::Tick => Msg::Tick, _ => Msg::Ignore, } } } struct SearchBox { query: String, results: Vec<String>, size: (u16, u16), } impl Model for SearchBox { type Message = Msg; fn init(&mut self) -> Cmd<Msg> { Cmd::log("search box ready") } fn update(&mut self, msg: Msg) -> Cmd<Msg> { match msg { Msg::Key(k) if k.is_char('q') => Cmd::quit(), Msg::Key(k) => { if let KeyCode::Char(c) = k.code { self.query.push(c); } Cmd::task(|| Msg::SearchDone(vec!["hit".into()])) } Msg::Resize(w, h) => { self.size = (w, h); Cmd::none() } Msg::SearchDone(r) => { self.results = r; Cmd::none() } _ => Cmd::none(), } } fn view(&self, frame: &mut Frame) { let area = Rect::new(0, 0, frame.width(), 1); Paragraph::new(self.query.as_str()).render(area, frame); for (i, r) in self.results.iter().enumerate() { let y = (i + 2) as u16; let row = Rect::new(0, y, frame.width(), 1); Paragraph::new(r.as_str()).render(row, frame); } } fn subscriptions(&self) -> Vec<Box<dyn Subscription<Msg>>> { vec![Box::new(Every::new(Duration::from_millis(500), || Msg::Tick))] } fn on_shutdown(&mut self) -> Cmd<Msg> { Cmd::log(format!("final query: {}", self.query)) } }

Pitfalls

Don’t call Program::run inside update. The runtime is single- threaded with respect to your model; re-entering the run loop deadlocks. Use commands for nested flows.

Don’t clone the world in view. view runs every frame that is dirty; allocation there shows up in the frame budget. Use the widgets’ borrow-and-draw APIs instead.

Cross-references