ftui-runtime — Overview
ftui-runtime is the orchestrator. It consumes events produced by
ftui-core, dispatches them into your
Model::update, calls Model::view to build a
Frame, and pushes the resulting Buffer through a
BackendPresenter onto the terminal. Everything else in the workspace —
widgets, layout, style, intelligence, testing — either produces data the
runtime consumes or consumes data the runtime produces.
If a piece of code mentions a Cmd, a Subscription, a
ProgramConfig, a tick strategy, or a rollout lane, it lives here.
Why a dedicated runtime crate?
The Elm architecture (adopted by Bubbletea in Go) has three parts —
init / update / view — and a single loop that drives them. In Rust the
loop needs more than just dispatch:
- Cancellation-safe subscriptions (timers, file watchers, child processes) whose lifecycles have to be reconciled each cycle.
- Deterministic effect execution (
Cmd::Task,Cmd::Tick,Cmd::SaveState) with back-pressure and telemetry. - Coordinated terminal output — the “one-writer rule” — so that inline logs never interleave with UI escape sequences.
- Migration safety — the runtime can run a shadow lane in parallel with the live lane to prove behaviour-preservation before a switch.
- Evidence — every non-trivial decision (resize, diff strategy, frame budget, rollout) emits a JSONL line a human can grep.
Doing all of that inside ftui-core would blur the input/output
boundary. Doing it inside widgets would make every widget author
re-invent it. ftui-runtime collects the responsibility in one crate,
which is why it is also the crate you use ftui::prelude::* through.
Layer boundaries
The runtime is the only crate allowed to import from ftui-backend
directly (via the optional native-backend feature). Widgets never see
a backend; they see a Frame. The backend never sees your Model; it
sees a Buffer and a BufferDiff.
What lives in this crate
- program.rs Model + Cmd + Program::run loop
- subscription.rs Subscription trait + Every built-in
- process_subscription.rs ProcessSubscription
- effect_system.rs queue telemetry + Cx-aware tasks
- evidence_sink.rs JSONL sink, ftui-evidence-v1
- state_persistence.rs StateRegistry + StorageBackend
- lens.rs bidirectional binding primitives
- terminal_writer.rs one-writer coordinator
- telemetry.rs optional OTLP integration
- telemetry_schema.rs canonical targets + events
- tick_strategy/ Uniform / Predictive / Custom
- resize_coalescer.rs BOCPD-backed resize policy
- simulator.rs ProgramSimulator for tests
Note that ftui-runtime also hosts the intelligence primitives
(BOCPD, conformal, VOI, e-processes, SOS barrier). That’s deliberate:
every one of them is consumed by the runtime’s own decisions (resize
coalescing, frame-budget risk gating, widget refresh selection). They
are re-exported for application reuse but the runtime owns them.
Minimal example
This is the smallest end-to-end program. It uses Every to tick the
model once a second and draws a counter. Later pages drill into every
type you see here.
use ftui::prelude::*;
use ftui_core::geometry::Rect;
use ftui_runtime::subscription::Every;
use ftui_widgets::paragraph::Paragraph;
use std::time::Duration;
#[derive(Clone, Debug)]
enum Msg { Tick, Quit }
impl From<Event> for Msg {
fn from(e: Event) -> Self {
match e {
Event::Key(k) if k.is_char('q') => Msg::Quit,
_ => Msg::Tick,
}
}
}
struct Counter { ticks: u64 }
impl Model for Counter {
type Message = Msg;
fn update(&mut self, msg: Msg) -> Cmd<Msg> {
match msg {
Msg::Tick => { self.ticks += 1; Cmd::none() }
Msg::Quit => Cmd::quit(),
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("ticks: {} (q to quit)", self.ticks);
let area = Rect::new(1, 1, frame.width().saturating_sub(1), 1);
Paragraph::new(text.as_str()).render(area, frame);
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Msg>>> {
vec![Box::new(Every::new(Duration::from_secs(1), || Msg::Tick))]
}
}
fn main() -> std::io::Result<()> {
Program::with_config(Counter { ticks: 0 }, ProgramConfig::default())?.run()
}Every piece of this — Cmd, Subscription, ProgramConfig, the
conversion from Event to your message type — is documented on its
own page in this section.
Two entrypoints: App (builder) vs Program::with_config
FrankenTUI exposes two ways to spin up the runtime. They compile to the same underlying loop, but target different audiences:
-
App::new(model).screen_mode(...).run()— the builder façade. Used by the tutorials (see hello-tick). Short, fluent, great for simple programs and first-read code.Appwraps aProgramConfiginternally and exposes only the knobs most apps need (screen mode, mouse capture, subscription overrides). -
Program::with_config(model, ProgramConfig::default().with_*(...))?.run()— the advanced escape hatch. Used when you need fine-grained control: selecting a runtime lane, installing an evidence sink, wiring aStateRegistryfor persistence, or tuning the effect queue. Everything the builder hides is a method onProgramConfig.
Rule of thumb: start with App in tutorials and demos, drop to
Program::with_config the first time you need a knob the builder does
not expose. See the ProgramConfig reference
for the full set.
The update cycle (one frame)
Phases are explained in full on the model-trait and subscriptions pages.
Pitfalls
Never write to stdout from inside update or view. A stray
println! will race the presenter and corrupt the terminal state.
Emit a Cmd::Log instead — it goes through the
single TerminalWriter and respects inline-mode cursor discipline.
update must not block. Anything that could take longer than a
frame (HTTP, disk I/O, cryptography) must be a
Cmd::Task. Blocking inside update stalls
input handling and subscriptions simultaneously.