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
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
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.