Hello, Tick
This is the smallest meaningful FrankenTUI program: a counter that
increments on every event, quits on q, and renders a single line. It
exists to show the shape of the Model trait without any widget-tree
noise.
Read this after installation and before embedding in a CLI. You should be able to paste this into a binary crate and have it run. The example uses inline mode with a 1-row UI, so it coexists with whatever is in your terminal’s scrollback.
The whole program is about 45 lines of Rust. It exercises the entire pipeline
from frame-pipeline: input event, update,
view, Frame, Buffer, Diff, Presenter, TerminalWriter. One
counter, four imports, one trait impl, one main.
Full program
use ftui_core::event::Event;
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_runtime::{App, Cmd, Model, ScreenMode};
use ftui_widgets::paragraph::Paragraph;
struct TickApp {
ticks: u64,
}
#[derive(Debug, Clone)]
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,
}
}
}
impl Model for TickApp {
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: {} (press 'q' to quit)", self.ticks);
let area = Rect::new(0, 0, frame.width(), 1);
Paragraph::new(text).render(area, frame);
}
}
fn main() -> std::io::Result<()> {
App::new(TickApp { ticks: 0 })
.screen_mode(ScreenMode::Inline { ui_height: 1 })
.run()
}Now let’s walk it.
Walk-through
Imports
use ftui_core::event::Event;
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_runtime::{App, Cmd, Model, ScreenMode};
use ftui_widgets::paragraph::Paragraph;Five crates, five types. Event and Rect are the plumbing types.
Frame is the per-render drawing context. App, Cmd, Model, and
ScreenMode are the runtime surface. Paragraph is the one widget we
use.
The model
struct TickApp {
ticks: u64,
}Your model is a normal Rust struct. It holds all the state the UI needs
to render. TickApp has exactly one piece of state: a counter.
The message enum
#[derive(Debug, Clone)]
enum Msg {
Tick,
Quit,
}Msg is the closed set of things that can happen to the model. In an
Elm/Bubbletea runtime, update is a pure function of (state, msg) → (state, effect), so the first job when building any FrankenTUI app is
to sketch the message enum.
The event-to-message bridge
impl From<Event> for Msg {
fn from(e: Event) -> Self {
match e {
Event::Key(k) if k.is_char('q') => Msg::Quit,
_ => Msg::Tick,
}
}
}The runtime pumps Event values from the input layer. You must tell it
how to map those into your Msg. Here, q quits; anything else ticks.
Real apps will have a much richer match — arrow keys, mouse events,
resize, focus in/out. Each one is an Event variant.
The Model impl
impl Model for TickApp {
type Message = Msg;
fn update(&mut self, msg: Msg) -> Cmd<Msg> {
match msg {
Msg::Tick => {
self.ticks += 1;
Cmd::none()
}
Msg::Quit => Cmd::quit(),
}
}
// ...
}update mutates the model in place and returns a Cmd<Msg> describing
any side effect that should run after the render. Cmd::none() means
“nothing to do.” Cmd::quit() exits the program cleanly, letting
TerminalSession::Drop restore the terminal.
The view
fn view(&self, frame: &mut Frame) {
let text = format!("Ticks: {} (press 'q' to quit)", self.ticks);
let area = Rect::new(0, 0, frame.width(), 1);
Paragraph::new(text).render(area, frame);
}view is called with a mutable Frame. You compute what to draw and
call widgets. Paragraph::new(text).render(area, frame) is the simplest
possible widget render: draw a string into a rectangle.
Notice: view is pure. It reads self and writes to frame, and
does nothing else. No clock reads, no file I/O, no globals. This is what
makes snapshot tests and shadow-run validation work.
The entry point
fn main() -> std::io::Result<()> {
App::new(TickApp { ticks: 0 })
.screen_mode(ScreenMode::Inline { ui_height: 1 })
.run()
}App::new takes your initial model. The builder chain configures the
screen mode (inline with a 1-row UI). run() enters the event loop, and
returns when your model calls Cmd::quit() or a fatal error occurs.
What happens when you press a key
Every keystroke drives one full pipeline pass. At 60 Hz idle, the runtime also polls subscriptions — but this example has none, so no render fires without input.
Adding a tick subscription
If you want the counter to advance automatically on a timer, add a subscription:
use std::time::Duration;
use ftui_runtime::Subscription;
use ftui_runtime::subscription::Every;
impl Model for TickApp {
type Message = Msg;
// ... update, view unchanged ...
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Msg>>> {
vec![Box::new(Every::new(Duration::from_millis(500), || Msg::Tick))]
}
}Now Msg::Tick arrives twice a second even without user input, and the
counter climbs on its own.
Swapping to alt-screen mode
Change one line to take over the full terminal:
.screen_mode(ScreenMode::AltScreen)On exit, TerminalSession::Drop leaves the alt screen and the user’s
original scrollback is intact — no magic required.
See embedding in a CLI for when to pick inline vs alt-screen.
Pitfalls
Don’t read the wall clock inside view(). If you need the current
time, plumb it through an Every subscription and store the current
time in the model. Otherwise your snapshots will flake and the
pipeline stops being deterministic.
Don’t call std::process::exit() from inside update(). Use
Cmd::quit(). exit() bypasses TerminalSession::Drop and leaves the
terminal in raw mode.
Don’t spawn a thread that writes to stdout. The one-writer rule is
real. Model background work as a Subscription that emits messages;
the widget tree and writer then handle the update on the next render
cycle.