Ratatui: Events, Async & Application Architecture
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Ratatui draws; it does not listen. The library renders widgets into a cell buffer and writes the difference to the terminal, and everything that happens between two frames (reading keys, mouse clicks, pasted text and resize notifications, waiting for a timer or a network answer, deciding what the next state is) belongs to the application and to the backend library it chose. This page covers that half of a terminal user interface: how events arrive on Windows, macOS and Linux, how to structure the code that reacts to them (the three architectures that the Ratatui documentation describes), how to add asynchronous work with Tokio without blocking the screen, how to leave the terminal usable when something goes wrong, what the backends are, how to draw a small interface inside the normal scrollback with an inline viewport, and what costs time in the loop.
The page was written against Ratatui 0.30.2 (with ratatui-core 0.1.2), crossterm 0.29.0, termion 4.0.6,
termwiz 0.23.3 and color-eyre 0.6.5, as listed on crates.io and docs.rs on 2026-10-25, and the 2024 edition of
Rust (Ratatui 0.30 needs Rust 1.88 or newer). Tokio 1.x, tokio-util 0.7 and futures 0.3 are used in the async
examples. Ratatui has no 1.0 release and no published 1.0 roadmap, so any 0.x release can break an API; the
release highlights and the
breaking changes file are the places to check after a
version bump. Neither cargo nor rustc is installed on the machine used to write this page, so the Rust examples
were not compiled or run; every type, method and feature flag used in them was checked against docs.rs and against the
Ratatui website sources, and the examples follow the shapes of the official examples. Compile each snippet before
relying on it.
The terminal fundamentals behind raw mode, the alternate screen and escape sequences are on
Terminals, TTYs & Pseudo-Terminals and
Escape Sequences & Terminal Capabilities; the general
shape of a TUI (main loop, rendering pipeline, state patterns) on
TUI Architecture & Concepts. The first Ratatui program, the crate
split and ratatui::run are on Ratatui: Getting Started, widgets and
layout on Ratatui: Layout, Widgets & Styling and testing on
Ratatui: Testing & Ecosystem. Whether a full-screen interface should
exist at all, and the rule that it is a view over a command that also works without a terminal, is on
TUIs vs. CLIs.
The running example is shelf tui, the full-screen catalogue browser of the Bookshelf scenario: a list of books that
the user moves through, borrows and returns, while a background request to the shelfd daemon is in flight.
Where events come from
Ratatui does not expose any event catching: its own documentation says the programmer depends on the chosen backend’s
library for input. With the default backend that library is crossterm, which ratatui re-exports as
ratatui::crossterm, so an application does not have to add the
dependency just to read keys. The types are the same ones whether you import them as ratatui::crossterm::event or from
a direct crossterm = "0.29" dependency, as long as Cargo resolves a single crossterm version (see
Backends for what happens when it does not).
crossterm’s Event enum has six variants:
| Variant | Payload | When it arrives |
|---|---|---|
|
|
A key was pressed; on Windows also when it is released; on Unix also on release and repeat only if the terminal supports and was asked for the kitty keyboard protocol |
|
|
Only after |
|
|
Text pasted into the terminal, only after |
|
|
The terminal window changed size; the docs note that resize events can occur in batches |
|
none |
Only after |
Keyboard events need raw mode, which ratatui::init and ratatui::run turn on. Mouse and focus events are off by
default. The same module offers two ways to consume events, and the crossterm documentation is explicit that they must
not be mixed:
| Style | API | Use it when |
|---|---|---|
Blocking |
|
The only thing that can change the screen is user input |
Polling |
|
The loop also has to do something periodically (a clock, a spinner, a redraw) on one thread |
Asynchronous |
|
The application already runs on Tokio or async-std and waits on several sources at once |
read and poll must not be called from different threads, and neither may be combined with EventStream: pick one
style for the whole program.
A synchronous loop with polling
The smallest event loop is the one from the Ratatui documentation: draw, wait for one event, update, repeat. Adding
poll with a timeout turns "wait for input" into "wait for input or the next tick", which is enough for a clock or a
spinner and needs no threads. The listing below is a complete shelf tui browser written that way. It moves a
selection with the arrow keys or j and k, shows a status line and ignores everything that is not a key press.
[package]
name = "shelf-tui"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.30"
use std::{
io,
time::{Duration, Instant},
};
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout},
style::{Modifier, Style},
widgets::{Block, List, ListItem, ListState, Paragraph},
};
struct Book {
title: &'static str,
on_loan: bool,
}
struct App {
books: Vec<Book>,
list: ListState,
ticks: u64,
exit: bool,
}
impl App {
fn new() -> Self {
let books = vec![
Book { title: "The Rust Programming Language", on_loan: false },
Book { title: "Programming Pearls", on_loan: true },
Book { title: "The Pragmatic Programmer", on_loan: false },
];
Self { books, list: ListState::default().with_selected(Some(0)), ticks: 0, exit: false }
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
let tick_rate = Duration::from_millis(250);
let mut last_tick = Instant::now();
while !self.exit {
terminal.draw(|frame| self.view(frame))?;
// Wait for input, but never longer than the time left until the next tick.
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
match event::read()? {
// On Windows a key produces a Press and a Release event: react to presses only.
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key.code, key.modifiers),
// Resize needs no code here: Terminal::draw adapts a full-screen viewport itself.
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
self.ticks += 1;
last_tick = Instant::now();
}
}
Ok(())
}
fn on_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
match code {
KeyCode::Char('q') | KeyCode::Esc => self.exit = true,
// Raw mode disables SIGINT: Ctrl+C arrives as an ordinary key event.
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => self.exit = true,
KeyCode::Down | KeyCode::Char('j') => self.list.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.list.select_previous(),
KeyCode::Enter => {
if let Some(book) = self.list.selected().and_then(|i| self.books.get_mut(i)) {
book.on_loan = !book.on_loan;
}
}
_ => {}
}
}
fn view(&mut self, frame: &mut Frame) {
let [main, status] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let items: Vec<ListItem> = self
.books
.iter()
.map(|b| ListItem::new(format!("{:<10} {}", if b.on_loan { "on loan" } else { "on shelf" }, b.title)))
.collect();
let list = List::new(items)
.block(Block::bordered().title("shelf"))
.highlight_style(Style::new().add_modifier(Modifier::REVERSED))
.highlight_symbol("> ");
frame.render_stateful_widget(list, main, &mut self.list);
frame.render_widget(
Paragraph::new(format!("j/k move, Enter toggles loan, q quits (tick {})", self.ticks)),
status,
);
}
}
fn main() -> io::Result<()> {
ratatui::run(|terminal| App::new().run(terminal))
}
Source: ratatui::run and
crossterm::event (the poll and read loop shape), with
the duplicate key event handling from the Ratatui FAQ.
Three details of that loop are worth keeping. The timeout passed to poll is the remaining time until the next tick,
not a fixed value, so a burst of key presses cannot starve the tick. Terminal::draw resizes a full-screen viewport’s
buffers automatically when the terminal size changed, so the Resize event usually needs no explicit code in a
full-screen application (an inline viewport is different, see Inline viewports). And the draw happens before the
wait, so the first frame appears immediately.
Key events on Windows, macOS and Linux
The same key press produces a different number of events depending on the platform and the terminal, and this is the
most common cross-platform bug in a Ratatui application. The Ratatui FAQ states it directly: with crossterm on Windows
the code CrosstermEvent::Key(e) => tx.send(Event::Key(e)) sends the event twice, once with KeyEventKind::Press and
once with KeyEventKind::Release, while macOS and Linux only generate Press.
| Platform | KeyEvent.kind |
Consequence |
|---|---|---|
Windows |
Always set, and both |
Filter on |
macOS, Linux (Unix terminals) |
Set only when the |
The filter is harmless and keeps the code identical on every platform |
Any terminal with the kitty keyboard protocol enabled |
|
Handle |
crossterm exposes the progressive keyboard enhancement protocol (the "kitty keyboard protocol") through
PushKeyboardEnhancementFlags and PopKeyboardEnhancementFlags, with four flags in KeyboardEnhancementFlags:
DISAMBIGUATE_ESCAPE_CODES (Escape and modified keys as unambiguous CSI-u sequences, and KeyEvent.state is only
filled in with this flag), REPORT_EVENT_TYPES (repeat and release events), REPORT_ALTERNATE_KEYS and
REPORT_ALL_KEYS_AS_ESCAPE_CODES (required to get repeat and release events for plain text keys). The crossterm
documentation lists kitty, foot, WezTerm, alacritty, notcurses, neovim, kakoune and dte as the terminals supporting it,
and notes that alternate keys and Unicode codepoints are not supported by crossterm yet. crossterm::terminal::supports_keyboard_enhancement()
asks the terminal whether the protocol is available; on Unix it blocks and can time out, so it must not run while
another thread is inside read or poll. The protocol is a community convention rather than a standard (see
Escape Sequences & Terminal Capabilities), so the
application must always work without it: without it Ctrl+Enter and Shift+Enter, for example, cannot be told apart from
Enter on most Unix terminals.
use ratatui::crossterm::{
event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
execute,
terminal::supports_keyboard_enhancement,
};
use std::io::stdout;
fn enable_enhanced_keys() -> std::io::Result<bool> {
// Call this before the event loop starts, never while another thread is reading events.
if !supports_keyboard_enhancement()? {
return Ok(false);
}
execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
)
)?;
Ok(true)
}
fn disable_enhanced_keys() -> std::io::Result<()> {
execute!(stdout(), PopKeyboardEnhancementFlags)
}
Mouse, paste, focus and resize
Mouse, bracketed paste and focus reporting are modes that the terminal only enters when it receives the matching escape
sequence, so each has an enable command and a disable command, and the application owns the pairing. The Ratatui
documentation lists raw mode, the alternate screen and mouse capture as features that every backend implements in its
own way, and the documentation of ratatui::init mentions only raw mode and the alternate screen as what it sets up and
restore as what undoes it; enable the extra modes after init and disable them before restore, in every exit path.
| Mode | crossterm commands | Notes |
|---|---|---|
Mouse capture |
|
Works on Windows 10 and later and on Unix terminals. Some platforms do not report the button for |
Bracketed paste |
|
Delivers pasted text (crossterm’s |
Focus change |
|
Delivers |
Resize |
none (always delivered) |
|
The next listing extends the polling loop with the three optional modes. It shows only what changes: the setup and
teardown in main, and the new match arms.
use std::io::{self, stdout};
use ratatui::crossterm::{
event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event, MouseEventKind,
},
execute,
};
fn main() -> io::Result<()> {
let mut terminal = ratatui::init();
// Everything enabled here must be disabled again on every exit path.
execute!(stdout(), EnableMouseCapture, EnableBracketedPaste, EnableFocusChange)?;
let result = App::new().run(&mut terminal);
execute!(stdout(), DisableFocusChange, DisableBracketedPaste, DisableMouseCapture)?;
ratatui::restore();
result
}
impl App {
// Called from the match in `run` for every event that is not a key press.
fn on_other_event(&mut self, event: Event) {
match event {
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollDown => self.list.select_next(),
MouseEventKind::ScrollUp => self.list.select_previous(),
_ => {}
},
// One event for the whole pasted text, even if it contains newlines.
Event::Paste(text) => self.search.push_str(text.trim_end()),
Event::FocusLost => self.paused = true,
Event::FocusGained => self.paused = false,
_ => {}
}
}
}
Source: crossterm::event,
MouseEvent and
ratatui::init. The search and paused fields belong to the
application and are not part of the earlier listing.
If the terminal is left in mouse-capture mode after a crash, the shell prints escape sequences when the mouse moves.
That is the same failure as a missing restore, and the panic hooks below must undo every extra mode too.
Event-handling styles
The Ratatui documentation describes three ways to organise the code that handles events. They are about where the
match on events lives, and they combine with any of the architectures in the next section.
| Style | How it works | Advantage | Drawback |
|---|---|---|---|
Centralized event handling |
One |
No message passing, every key binding in one place |
Does not scale: key bindings of different screens cannot be split into separate files |
Centralized catching, message passing |
One place polls for events and sends messages or calls sub-functions |
Easy to split a large |
Needs a main loop that keeps polling in the central place |
Distributed event loops |
A sub-module receives the |
No central loop to update when a module is added |
Several modules with similar loops duplicate code |
The loop above is the first style. The Elm and Flux architectures below are the second one, and the async design in the Tokio section is the second one with the "central place" moved into its own task.
Application architectures
Ratatui is a library, not a framework: it does not impose a structure, and the website documents three patterns under "Application Patterns". The documentation presents them as patterns to learn from; for a framework that enforces The Elm Architecture on top of Ratatui it points to tui-realm (see Ratatui: Testing & Ecosystem). The pages do not rank the patterns.
| Pattern | Pieces | State lives in | Best for |
|---|---|---|---|
The Elm Architecture (TEA) |
Model, Message, |
One model for the whole application |
Small and medium applications, easy to reason about and to test as a state machine |
Component architecture |
A |
Inside each component |
Larger applications with independent panes (list, details, search box) |
Flux |
Actions, a dispatcher, stores and views |
In stores, with strictly one-way data flow |
Many views reading shared data from several sources (a chat client is the documented real-world example) |
The Elm Architecture
TEA splits the program into a Model (all the state), an update function (current model plus a message gives the next
model) and a view function (model to screen). The Ratatui page describes the loop as: listen for input, map the input
to a Message, pass it to update, draw with view, repeat.
Two refinements from the documentation matter in Rust. First, update may return another Message, which makes the
program a finite state machine whose transitions can chain; the main loop then calls update again until it returns
None. Second, the documentation notes that strict immutability is optional: update(model: &mut Model, msg: Message)
is a valid variant, and StatefulWidget types such as List need a mutable reference to their state while rendering, so the
view function often takes &mut Model and the Frame and stays free of side effects other than rendering (and the
cursor position, which is also set through the Frame).
The shelf tui loan limit shows the chained message: a ToggleLoan that would exceed the limit does not change the
model but returns LimitReached, and the loop feeds that message back into update.
use std::io;
use ratatui::{
Frame,
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Constraint, Layout},
style::{Modifier, Style},
widgets::{Block, List, ListItem, ListState, Paragraph},
};
const MAX_LOANS: usize = 2;
struct Book {
title: &'static str,
on_loan: bool,
}
struct Model {
books: Vec<Book>,
list: ListState,
status: String,
running: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Message {
Next,
Previous,
ToggleLoan,
LimitReached,
Quit,
}
fn update(model: &mut Model, message: Message) -> Option<Message> {
match message {
Message::Next => model.list.select_next(),
Message::Previous => model.list.select_previous(),
Message::ToggleLoan => {
let index = model.list.selected()?;
let loans = model.books.iter().filter(|b| b.on_loan).count();
if !model.books[index].on_loan && loans >= MAX_LOANS {
return Some(Message::LimitReached);
}
let book = &mut model.books[index];
book.on_loan = !book.on_loan;
model.status = format!("{} is now {}", book.title, if book.on_loan { "on loan" } else { "on the shelf" });
}
Message::LimitReached => model.status = format!("A member can borrow at most {MAX_LOANS} books"),
Message::Quit => model.running = false,
}
None
}
fn view(model: &mut Model, frame: &mut Frame) {
let [main, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let items: Vec<ListItem> = model
.books
.iter()
.map(|b| ListItem::new(format!("[{}] {}", if b.on_loan { "x" } else { " " }, b.title)))
.collect();
let list = List::new(items)
.block(Block::bordered().title("shelf"))
.highlight_style(Style::new().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(list, main, &mut model.list);
frame.render_widget(Paragraph::new(model.status.as_str()), status);
}
fn to_message(event: Event) -> Option<Message> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Down | KeyCode::Char('j') => Some(Message::Next),
KeyCode::Up | KeyCode::Char('k') => Some(Message::Previous),
KeyCode::Enter => Some(Message::ToggleLoan),
KeyCode::Char('q') | KeyCode::Esc => Some(Message::Quit),
_ => None,
},
_ => None,
}
}
fn main() -> io::Result<()> {
let mut model = Model {
books: vec![
Book { title: "The Rust Programming Language", on_loan: false },
Book { title: "Programming Pearls", on_loan: false },
Book { title: "The Pragmatic Programmer", on_loan: false },
],
list: ListState::default().with_selected(Some(0)),
status: String::new(),
running: true,
};
ratatui::run(|terminal| {
while model.running {
terminal.draw(|frame| view(&mut model, frame))?;
let mut message = to_message(event::read()?);
while let Some(current) = message {
message = update(&mut model, current);
}
}
Ok(())
})
}
Source: Ratatui — The Elm Architecture.
Component architecture
The component architecture takes "a slightly more object oriented, trait based approach". Each component encapsulates
its own state, event handlers and rendering. The documented trait has an initialisation step (init), event handlers
(handle_events, handle_key_events, handle_mouse_events), a state update (update, taking an Action) and a
rendering method (render, taking the Frame and the Rect the component may draw into). Compared with TEA, the
update logic is local rather than global and key handling is split across components; the stated advantage is that
handle_events, update and render of one piece of the screen sit together. The project-structure and trait are
spelled out in the official component template, available through cargo generate ratatui/templates (see
ratatui/templates); the real-world projects the page names are gobang and
edma.
A trimmed trait for shelf tui looks like this. It is the documented shape adapted to Ratatui 0.30 and not a type
exported by the crate.
use ratatui::{Frame, crossterm::event::KeyEvent, layout::Rect};
/// What a component asks the application to do after handling something.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Quit,
Tick,
Render,
FocusNext,
Search(String),
LoanBook(usize),
LoanFinished { index: usize, ok: bool },
Noop,
}
pub trait Component {
/// Called once, before the first frame.
fn init(&mut self) {}
/// Translate a key press into an action; components that do not care return Action::Noop.
fn handle_key_event(&mut self, _key: KeyEvent) -> Action {
Action::Noop
}
/// Apply an action to this component's private state; may produce a follow-up action.
fn update(&mut self, _action: &Action) -> Option<Action> {
None
}
/// Draw into the rectangle the parent layout assigned.
fn render(&mut self, frame: &mut Frame, area: Rect);
}
struct BookList { /* books: Vec<Book>, state: ListState */ }
struct DetailsPane { /* selected: Option<usize> */ }
struct SearchBox { /* text: String, focused: bool */ }
struct Home {
list: BookList,
details: DetailsPane,
search: SearchBox,
}
Source: Ratatui — Component Architecture.
The cost of the pattern is the traffic between components: a component cannot read another one’s state, so everything
crosses the Action type. That is the reason the async design below keeps an action channel in the middle.
Flux
Flux, introduced by Facebook for large web applications, keeps the data flow one-directional: a view (the widgets)
creates an action when the user does something, a dispatcher hands every action to the stores, and the stores hold
the state and the logic and notify the views. In Ratatui terms the widgets do not own state, they only display it. The
documentation shows a dispatcher that owns a store and calls store.update(action), a store with a counter and an
Action enum, and points to a chat client (Yengas/rust-chat-server) as a real-world example. For shelf tui a sketch (the Book type is the one from the earlier listings) would look like this:
enum Action {
Select(usize),
LoanRequested(usize),
LoanConfirmed(usize),
}
struct CatalogueStore {
books: Vec<Book>,
selected: Option<usize>,
}
impl CatalogueStore {
fn update(&mut self, action: &Action) {
match action {
Action::Select(i) => self.selected = Some(*i),
Action::LoanConfirmed(i) => self.books[*i].on_loan = true,
Action::LoanRequested(_) => {}
}
}
}
struct Dispatcher {
catalogue: CatalogueStore,
// further stores: loans, settings, search results ...
}
impl Dispatcher {
fn dispatch(&mut self, action: Action) {
// The dispatcher has no logic of its own: every store sees every action.
self.catalogue.update(&action);
}
}
Source: Ratatui — Flux Architecture.
The three patterns overlap more than the names suggest. TEA’s Message and the component and Flux Action are the same
idea (a value describing what happened), and the async design in the next section uses Action as its vocabulary while
keeping TEA’s single update function.
Async with Tokio
The Ratatui FAQ asks the question first: is async worth it? Ratatui is not an async library. Its single point of contact
is terminal.draw(|f| ui(f)), which is fast because of the double-buffer diffing, and the only other part that benefits
from async is reading input, which crossterm’s EventStream makes possible. The FAQ’s conclusion is that async pays off
when other parts of the application need it, such as network calls or several concurrent timers, and that otherwise the
polling loop above is simpler. For shelf tui, asking shelfd for the loan state over its local socket while the screen
keeps moving is exactly such a case.
The FAQ and the async template describe three shapes, from simple to elaborate:
-
One thread: get a key event, update state, render, and spawn Tokio tasks from
updatewhose results arrive in a later iteration. -
Three tasks: an event task maps events to actions and sends them on an
action_txchannel, a main task receives and dispatches actions and updates state, and a separate render task receives render requests. -
The "simple async" shape used on this page: an event task sends events to the main task over
event_tx, the main task maps events to actions, updates state and renders. The Ratatui async tutorial and the website’sTuirecipe use this shape with anEventenum (Init,Quit,Error,Tick,Render,Key,Mouse,Resizeand so on) produced by one task.
The diagram is the design of the next listing. Three roles matter: the event task is the only
reader of terminal input and also the clock (it turns elapsed time into Tick and Render events); the main loop is
the only writer of the application state and the only caller of draw; and background tasks never touch the state,
they send an Action back through the same channel the keyboard uses, so a finished request is processed exactly like a
key press.
Dependencies
[package]
name = "shelf-tui-async"
version = "0.1.0"
edition = "2024"
[dependencies]
color-eyre = "0.6"
crossterm = { version = "0.29", features = ["event-stream"] }
futures = "0.3"
ratatui = "0.30"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
EventStream is not available by default; the event-stream feature of crossterm enables it, and the website’s async
tutorial says the same. Depending on crossterm directly is correct here because the feature must be enabled on the same
crossterm version that Ratatui uses (0.29 for Ratatui 0.30); Cargo then compiles one crossterm with the union of the
features.
The event task
The event task combines three sources with tokio::select!: the EventStream, a tick interval (application logic such
as a spinner or a poll of shelfd) and a render interval (how often the screen may be redrawn). Separating the two
rates is what the website’s Tui recipe does: it defaults to 4 ticks and 60 frames per second. A tokio::time::interval completes its first tick immediately, panics if the period is zero, and by default
(MissedTickBehavior::Burst) fires missed ticks as fast as possible to catch up; for the render interval Skip is the
sensible choice, because queuing frames that nobody can see only makes a slow terminal slower.
use std::time::Duration;
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
use futures::StreamExt;
use tokio::{
sync::mpsc,
time::{MissedTickBehavior, interval},
};
#[derive(Debug)]
pub enum Event {
Tick,
Render,
Key(KeyEvent),
Resize,
Closed,
}
pub fn spawn_event_task(tick_rate: f64, frame_rate: f64) -> mpsc::UnboundedReceiver<Event> {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut reader = EventStream::new();
let mut ticks = interval(Duration::from_secs_f64(1.0 / tick_rate));
let mut frames = interval(Duration::from_secs_f64(1.0 / frame_rate));
frames.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
let event = tokio::select! {
_ = ticks.tick() => Some(Event::Tick),
_ = frames.tick() => Some(Event::Render),
next = reader.next() => match next {
// Windows also sends Release events: forward presses only.
Some(Ok(CrosstermEvent::Key(key))) if key.kind == KeyEventKind::Press => Some(Event::Key(key)),
Some(Ok(CrosstermEvent::Resize(_, _))) => Some(Event::Resize),
Some(Ok(_)) => None,
Some(Err(_)) | None => Some(Event::Closed),
},
};
let Some(event) = event else { continue };
let closed = matches!(event, Event::Closed);
// A failed send means the receiver was dropped: the application is shutting down.
if tx.send(event).is_err() || closed {
break;
}
}
});
rx
}
Source: Ratatui — Terminal and Event Handler, Ratatui — Async Event Stream, crossterm::event::EventStream and tokio::time::interval.
The website recipe stops its task with a CancellationToken from tokio-util; the listing above stops it when the
receiver is dropped instead, which is enough for a program that exits after the main loop. Use the token when the task
must be stopped and restarted, for example to hand the terminal to an external editor (the website has a "spawn vim"
recipe for that case).
The application and the action channel
The main loop is a second select!: events from the event task and actions from the action channel. Events are mapped
to actions (handle_event), and actions are applied to the state (update). Render is just another action, so a
redraw is requested the same way as everything else. The loan request is the asynchronous part: update spawns a task
that talks to shelfd (simulated here with a sleep) and reports back with Action::LoanFinished.
mod events; // the listing from the previous section, in src/events.rs
use std::time::Duration;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
style::{Modifier, Style},
widgets::{Block, List, ListItem, ListState, Paragraph},
};
use tokio::sync::mpsc::{self, UnboundedSender};
use events::{Event, spawn_event_task};
#[derive(Debug)]
enum Action {
Quit,
Tick,
Render,
Next,
Previous,
RequestLoan,
LoanFinished { index: usize, ok: bool },
}
struct Book {
title: &'static str,
on_loan: bool,
pending: bool,
}
struct App {
books: Vec<Book>,
list: ListState,
status: String,
ticks: u64,
should_quit: bool,
action_tx: UnboundedSender<Action>,
}
impl App {
fn handle_event(&self, event: Event) -> Option<Action> {
match event {
Event::Tick => Some(Action::Tick),
Event::Render | Event::Resize => Some(Action::Render),
Event::Closed => Some(Action::Quit),
Event::Key(key) => self.handle_key(key),
}
}
fn handle_key(&self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit),
// Raw mode: Ctrl+C is a key event, not a signal.
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit),
KeyCode::Down | KeyCode::Char('j') => Some(Action::Next),
KeyCode::Up | KeyCode::Char('k') => Some(Action::Previous),
KeyCode::Enter => Some(Action::RequestLoan),
_ => None,
}
}
fn update(&mut self, action: Action) {
match action {
Action::Quit => self.should_quit = true,
Action::Tick => self.ticks += 1,
Action::Render => {} // drawn by the main loop
Action::Next => self.list.select_next(),
Action::Previous => self.list.select_previous(),
Action::RequestLoan => {
let Some(index) = self.list.selected() else { return };
if self.books[index].pending {
return;
}
self.books[index].pending = true;
self.status = format!("Asking shelfd about \"{}\"", self.books[index].title);
let tx = self.action_tx.clone();
// Background work reports back through the same channel as the keyboard.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(600)).await; // stands in for the IPC call
let _ = tx.send(Action::LoanFinished { index, ok: true });
});
}
Action::LoanFinished { index, ok } => {
let book = &mut self.books[index];
book.pending = false;
if ok {
book.on_loan = !book.on_loan;
}
self.status = format!("{}: {}", book.title, if book.on_loan { "on loan" } else { "on the shelf" });
}
}
}
fn view(&mut self, frame: &mut Frame) {
let [main, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let items: Vec<ListItem> = self
.books
.iter()
.map(|b| {
let mark = if b.pending { "..." } else if b.on_loan { "[x]" } else { "[ ]" };
ListItem::new(format!("{mark} {}", b.title))
})
.collect();
let list = List::new(items)
.block(Block::bordered().title("shelf"))
.highlight_style(Style::new().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(list, main, &mut self.list);
frame.render_widget(Paragraph::new(format!("{} (tick {})", self.status, self.ticks)), status);
}
async fn run(&mut self, terminal: &mut DefaultTerminal, mut actions: mpsc::UnboundedReceiver<Action>) -> Result<()> {
let mut events = spawn_event_task(4.0, 30.0);
while !self.should_quit {
tokio::select! {
Some(event) = events.recv() => {
if let Some(action) = self.handle_event(event) {
let _ = self.action_tx.send(action);
}
}
Some(action) = actions.recv() => match action {
Action::Render => {
terminal.draw(|frame| self.view(frame))?;
}
other => self.update(other),
},
else => break,
}
}
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?; // install error and panic reporting before ratatui::init, see "Panic and error hooks"
let (action_tx, action_rx) = mpsc::unbounded_channel();
let mut app = App {
books: vec![
Book { title: "The Rust Programming Language", on_loan: false, pending: false },
Book { title: "Programming Pearls", on_loan: true, pending: false },
Book { title: "The Pragmatic Programmer", on_loan: false, pending: false },
],
list: ListState::default().with_selected(Some(0)),
status: String::from("j/k move, Enter toggles loan, q quits"),
ticks: 0,
should_quit: false,
action_tx,
};
let mut terminal = ratatui::init();
let result = app.run(&mut terminal, action_rx).await;
ratatui::restore();
result
}
Source: Ratatui — Async Counter App, the Ratatui FAQ (when to use Tokio), the async template and tokio::select!.
Points where the design can go wrong:
-
Do not block inside
updateorview. Both run on the thread that also reads actions. A slow call there delays every key press; move it into a spawned task and report with an action, asRequestLoandoes. -
Handlers of one
select!run one at a time. That is what makes the mutable access toselfsafe, but a long handler still starves the other branch, so keep the work inside a branch short. -
Unbounded channels have no back-pressure. The tick and render rates bound the events from the clock; keys are produced at human speed. Use
mpsc::channel(n)for a producer that can outrun the consumer, such as a log tail. -
A panic inside a spawned task does not stop the program. The panic hook still runs (and restores the terminal), but the task is gone and the main loop keeps waiting. Join the handles, or send
Event::Closedfrom a drop guard, for tasks the application cannot live without. -
Use
else => break(or an explicitClosedevent) so a closed channel ends the loop instead of panickingselect!when all branches are disabled. -
Older tutorial code. The website’s async tutorial pins Ratatui 0.28 and crossterm 0.28 and the template crates may lag behind; names such as
Frame::sizeandTerminal::set_cursorwere deprecated in favour ofFrame::areaandTerminal::set_cursor_position.
Panic and error hooks
A TUI changes global terminal state (raw mode, alternate screen, mouse capture), and a panic prints its message and backtrace into whatever state the terminal is in: inside the alternate screen the message disappears when the screen is left, and in raw mode newlines do not return the cursor to column 0. The Ratatui documentation therefore says that applications should use a panic hook to disable raw mode and return to the main screen.
| Setup | What restores the terminal |
|---|---|
|
Every initialisation function installs a panic hook that restores the terminal before the panic proceeds. The documentation asks to call them after installing any other panic hooks, so that the terminal is restored before those hooks print anything |
|
Restores on the normal path. Errors are printed to stderr and it does not panic; |
|
Nothing: you write the hook, the pattern is |
For the common case the order is the whole recipe: install the error reporter first, then initialise the terminal, and
restore before the error is returned from main so that the report prints on the normal screen.
use color_eyre::{Result, eyre::WrapErr};
fn main() -> Result<()> {
color_eyre::install()?; // 1. error and panic reports
let mut terminal = ratatui::init(); // 2. raw mode, alternate screen, and a hook that restores first
let result = run(&mut terminal).wrap_err("shelf tui failed");
ratatui::restore(); // 3. leave the alternate screen before the report is printed
result
}
fn run(terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> {
// the event loop from the previous sections
Ok(())
}
Source: ratatui::init, Ratatui — color_eyre Error Hooks and the inline example, which uses the same order.
When the terminal is set up by hand (a custom backend, a Terminal<CrosstermBackend<Stderr>>, extra modes such as mouse
capture), the hook has to be written by the application. The pattern from the Ratatui panic hooks recipe saves the
current hook, installs a new one that restores the terminal and then calls the saved hook, and ignores errors while
restoring, because panicking inside a panic hook would lose the original message.
use std::io::{self, stdout};
use ratatui::crossterm::{
event::{DisableBracketedPaste, DisableMouseCapture},
execute,
terminal::{LeaveAlternateScreen, disable_raw_mode},
};
/// Undo every mode the application enabled, in reverse order. Safe to call twice.
pub fn restore_terminal() -> io::Result<()> {
execute!(stdout(), DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen)?;
disable_raw_mode()
}
pub fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = restore_terminal(); // ignore errors: we are already failing
original(info); // then print the normal panic message on the restored screen
}));
}
Source: Ratatui — Setup Panic Hooks.
Things the hooks do and do not cover:
-
Termion and Termwiz need more work. The recipe explains that termion keeps the saved terminal state inside its
RawTerminaltype, so the hook has to create one while the terminal is still cooked and suspend raw mode, and that itsIntoAlternateScreenwrapper only leaves the alternate screen when dropped, so useToAlternateScreenandToMainScreenand flush stdout. For Termwiz the recipe’s section is an unfinished "TODO": the official documentation gives no worked example, and the methods to leave raw mode and the alternate screen need mutable access to the terminal instance. -
panic = "abort"skips destructors but not the hook. A guard that restores inDropnever runs when the process aborts, while the panic hook still does, so the hook is the reliable place to restore. -
Signals are not panics. In raw mode Ctrl+C is a key event, so the usual interrupt becomes an application decision.
SIGTERM(a service manager stopping the process,kill) andSIGHUP(the terminal window closing) terminate the process without running the hook or anyDrop, and the terminal is left raw. The Ratatui documentation does not cover signals; handle them withsignal-hookortokio::signalas described on POSIX Processes, Signals & I/O, and send a quit action when one arrives. On Windows the equivalent is the console control handler, see Windows Console & Processes. -
Log to a file, not to the screen. Anything printed with
println!oreprintln!while the alternate screen is active corrupts the display. The Ratatui website has a recipe for logging withtracingto a file. -
Decide where the output goes.
Terminal::new(CrosstermBackend::new(std::io::stdout()))renders nothing visible inshelf tui | cat, while stderr would still render. The FAQ recommends stdout, which is buffered and faster, and suggests wrapping stderr in aBufWriterif it is needed; the stdout-for-data rule from Streams & Exit Codes argues for refusing to start the TUI whenstd::io::stdout().is_terminal()is false and printing a plain list instead.
Backends
A backend is the library through which Ratatui talks to the terminal: it draws styled text, moves the cursor and reports the window size. The application normally also uses the backend library directly for input events, raw mode, the alternate screen and mouse capture. Ratatui 0.30.2 supports these:
| Backend | Feature and type | Platforms | Notes |
|---|---|---|---|
crossterm |
|
Windows, macOS, Linux |
The default and the most used; the only backend for which |
termion |
|
Unix only (not available on Windows) |
Has its own event API; the panic hook needs the extra steps above |
termwiz |
|
The Ratatui documentation states no platform list |
From the WezTerm project. The backend comparison recommends it for an interface that only WezTerm users will run |
termina |
|
Not stated in the Ratatui documentation |
The fourth backend in the documentation, re-exported as |
Test backend |
|
In memory |
Renders into a buffer for unit tests, see Ratatui: Testing & Ecosystem |
The website’s comparison page says to choose crossterm for most tasks, and its decision flow ends in crossterm unless the interface is only for WezTerm users (termwiz), the author does not need Windows and prefers termion, or prefers termina. Core functionality is the same across backends; what changes is the project structure and the input API. For Windows the practical consequence is that crossterm is the backend with a documented Windows path, see Windows Console & Processes for the console modes it relies on.
Since 0.30 the backends live in their own crates (ratatui-crossterm, ratatui-termion, ratatui-termwiz) that depend
on ratatui-core, so a change in one backend does not force a release of the others, and an application that needs
only one backend avoids the others' dependencies. With a non-crossterm backend, or a custom writer, the terminal is
built by hand:
use std::io::stdout;
use ratatui::{Terminal, backend::CrosstermBackend};
fn build_terminal() -> std::io::Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
// Raw mode and the alternate screen are now the application's job (the init helpers are not used).
Terminal::new(CrosstermBackend::new(stdout()))
}
Crossterm version conflicts
Two semver-incompatible crossterm versions in one dependency graph are a recurring source of confusing errors (expected
enum crossterm::event::Event, found enum crossterm::event::Event). The Ratatui documentation lists the consequences:
separate event queues (with possible race conditions and lost events), separately tracked raw mode (so raw mode may not
be restored correctly), and types that cannot be exchanged. Ratatui 0.30.2 uses crossterm 0.29 by default. To stay on
0.28, for a dependency that requires it, use the split backend crate and disable the defaults so that 0.29 is not also
pulled in:
[dependencies]
crossterm = "0.28"
ratatui = { version = "0.30", default-features = false }
ratatui-crossterm = { version = "0.1", default-features = false, features = ["crossterm_0_28"] }
Disabling the default features of ratatui also disables its other defaults (std, macros, all-widgets and the
rest), so re-enable the ones the application needs, and note that the main crate’s init/run helpers are not
available in this setup. cargo tree -p crossterm shows how many versions are in the graph.
Inline viewports
Everything so far uses the alternate screen: the interface takes over the terminal, and the previous contents come back
when it exits. Many command-line tools want something smaller: a progress display or a prompt that appears under the
shell prompt, scrolls with the rest of the output and leaves its log lines in the scrollback. Ratatui supports this with
Viewport::Inline.
Viewport has three variants:
| Variant | Behaviour |
|---|---|
|
The default for |
|
Draws inline with the normal terminal output. The number is the height in rows (clamped to the terminal height); the viewport spans the full terminal width and is anchored to column 0 of the cursor’s row, both at creation and when it is recomputed on resize. Ratatui reserves the rows and may scroll the terminal if the cursor is near the bottom |
|
Draws into a fixed rectangle of the screen, for applications that control only a part of it. It is not resized
automatically (call |
Only the full-screen viewport starts at the origin, so use frame.area() as the root rectangle of the layout and never
assume (0, 0). A terminal with a custom viewport is created with Terminal::with_options(backend, TerminalOptions {
viewport }) or, with the crossterm default, ratatui::init_with_options. In the table of initialisation functions of
the ratatui::init module, init_with_options and try_init_with_options enable raw mode but do not enter the
alternate screen, and, like the other init functions, they install the panic hook.
Output that should stay in the scrollback is written with Terminal::insert_before(height, |buf| ...), which draws
into a buffer of height rows and inserts it above the inline viewport; it has no effect when the viewport is not
inline, and lines that do not fit go to the scrollback. Without Ratatui’s scrolling-regions feature the viewport is
cleared and has to be repainted by the next draw (which can flicker); with the feature enabled the viewport is not
cleared and redrawn. The feature is optional, not in the default feature set, and uses terminal scrolling regions, so
test it on the terminals you target.
The program below is shelf import: it imports a list of books with a three-row progress display and, for each book,
leaves a permanent "imported" line above it. Only the full-screen viewport is documented to adapt to a new size inside draw, so a real inline program also
calls terminal.autoresize() on a resize event, as the official inline example does.
use std::{io, thread, time::Duration};
use ratatui::{
Frame, TerminalOptions, Viewport,
style::{Color, Style},
widgets::{Block, Gauge, Paragraph, Widget},
};
fn main() -> io::Result<()> {
let titles = ["The Rust Programming Language", "Programming Pearls", "The Pragmatic Programmer"];
// Raw mode on, alternate screen off: the viewport is three rows below the shell prompt.
let mut terminal = ratatui::init_with_options(TerminalOptions { viewport: Viewport::Inline(3) });
let result = (|| -> io::Result<()> {
for (done, title) in titles.iter().enumerate() {
terminal.draw(|frame| progress(frame, done, titles.len()))?;
thread::sleep(Duration::from_millis(400)); // stands in for parsing and storing the book
// A permanent line above the progress box, kept in the scrollback.
terminal.insert_before(1, |buf| {
Paragraph::new(format!("imported {title}")).render(buf.area, buf);
})?;
}
terminal.draw(|frame| progress(frame, titles.len(), titles.len()))?;
Ok(())
})();
ratatui::restore();
println!("imported {} books", titles.len());
result
}
fn progress(frame: &mut Frame, done: usize, total: usize) {
let gauge = Gauge::default()
.block(Block::bordered().title("shelf import"))
.gauge_style(Style::new().fg(Color::Green))
.ratio(done as f64 / total as f64);
frame.render_widget(gauge, frame.area());
}
Source: ratatui::Viewport,
ratatui::Terminal (insert_before, with_options),
ratatui::init and the
inline example. The
documentation does not say where the last frame of an inline viewport ends up after restore on each terminal, so try
the program on the terminals you support to see whether the final frame stays visible.
The inline viewport is anchored to the backend’s cursor row, so run it only when standard output is a terminal and
fall back to plain lines otherwise (the same is_terminal check as above). The documentation describes it as made for
"applications that want to embed a UI into a larger CLI flow". The caveats are those of any raw-mode program: the
mouse and paste modes still have to be disabled and a panic hook is still needed (the init functions install one).
Performance
Rendering is cheap compared with most of what the loop could do wrong. The FAQ notes that rendering to the buffer is relatively fast, especially with the double-buffer technique that writes only the differences, and that creating widgets can be done efficiently. The points that do cost time:
| Cost | What to do |
|---|---|
Drawing more often than needed |
Draw on state changes and on the render tick, not after every event. The official inline example sets |
A high frame rate |
The website’s |
Unbuffered output |
stdout is buffered, stderr is not; the FAQ says stderr costs performance that is unnoticeable in most applications and
can be wrapped in a |
Work inside |
The closure passed to |
Layout recomputation |
The |
Many key events from a paste |
Enable bracketed paste so a large paste is one |
Blocking the loop |
A synchronous |
Rendering itself is covered on TUI Architecture & Concepts (the cell
buffer and diff pipeline) and Ratatui: Layout, Widgets & Styling.
The Ratatui documentation gives no benchmark numbers for the loop, so measure with the real data set and a real terminal
before tuning, and test the interface without a terminal with the TestBackend described on
Ratatui: Testing & Ecosystem.
Pitfalls
-
Handling
ReleaseandRepeatas presses. Every action runs twice on Windows. Filter onKeyEventKind::Press. -
Mixing
read/pollwithEventStream, or calling them from two threads. The crossterm documentation forbids both. -
Two crossterm versions. Separate event queues and raw-mode state; check with
cargo tree -p crossterm. -
Leaving extra terminal modes enabled. Mouse capture, bracketed paste, focus change and keyboard enhancement flags are not part of the documented behaviour of
restore; disable them yourself on the normal path and in the panic hook. -
Printing while the TUI is active. Use a log file;
println!writes into the alternate screen. -
Assuming Ctrl+C is a signal. In raw mode it is a key event;
SIGTERMandSIGHUPstill kill the process without restoring the terminal. -
Writing to a terminal that is not one. Check
is_terminaland refuse or fall back; with stdout rendering a pipe shows nothing. -
Assuming the origin is (0, 0). True only for a full-screen viewport; use
frame.area(). -
Treating tutorial code as current. The website pages track several 0.x releases; compare with docs.rs for the version in
Cargo.toml.
What the official documentation does not provide
-
No 1.0 release or roadmap: APIs may change in any 0.x version.
-
No guidance on handling
SIGTERM,SIGHUP, suspend (SIGTSTP) or Windows console control events in a Ratatui application; the website’sTuirecipe has a suspend/resume pair for Unix, but the rest is left to the application. -
No worked panic hook for Termwiz (the recipe section is a placeholder) and no event-handling documentation for Termina beyond its listing as a backend.
-
No platform list for Termwiz and Termina in the backend documentation.
-
No statement of where an inline viewport’s last frame ends up after
restore. -
No benchmark figures for loop or render costs.
-
No accessibility guidance for full-screen interfaces; see TUI Architecture & Concepts for the limits of the format.
References
-
Ratatui and the pages used above: Event Handling, Application Patterns (The Elm Architecture, Component Architecture, Flux Architecture), Backends and Comparison of Backends, Setup Panic Hooks, color_eyre Error Hooks, Terminal and Event Handler, Async Counter App and FAQ
-
ratatui-website (the sources of those pages), ratatui/templates and async-template
-
ratatui on docs.rs: init, run, the init module, Terminal, TerminalOptions, Viewport, Frame, ListState and backend
-
crossterm::event, including EventStream, KeyEventKind, KeyEvent, MouseEvent, PushKeyboardEnhancementFlags, KeyboardEnhancementFlags and supports_keyboard_enhancement
-
tokio::time::interval, MissedTickBehavior and tokio::select!