Skip to Content
Getting startedHello, tick

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

src/main.rs
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:

src/main.rs (excerpt)
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.

Where next