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

Key

KeyEvent with code, modifiers, kind and state

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

Mouse

MouseEvent with kind, column, row and modifiers

Only after EnableMouseCapture

Paste

String

Text pasted into the terminal, only after EnableBracketedPaste and with the bracketed-paste crate feature

Resize

(u16, u16), columns and rows

The terminal window changed size; the docs note that resize events can occur in batches

FocusGained, FocusLost

none

Only after EnableFocusChange

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

event::read() returns the next Event, waiting if necessary

The only thing that can change the screen is user input

Polling

event::poll(timeout) returns whether an event is available within the timeout; a following read then does not block

The loop also has to do something periodically (a clock, a spinner, a redraw) on one thread

Asynchronous

EventStream, a Stream of Result<Event, std::io::Error>, behind crossterm’s event-stream feature

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 Press and Release are delivered

Filter on key.kind == KeyEventKind::Press (or key.is_press()), otherwise every action runs twice

macOS, Linux (Unix terminals)

Set only when the REPORT_EVENT_TYPES keyboard enhancement flag was enabled; otherwise every key is a Press

The filter is harmless and keeps the code identical on every platform

Any terminal with the kitty keyboard protocol enabled

Press, Repeat and Release

Handle Repeat deliberately: holding Down should keep scrolling, so accept Press and Repeat for movement keys and Press only for actions such as "loan this book"

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

EnableMouseCapture, DisableMouseCapture

Works on Windows 10 and later and on Unix terminals. Some platforms do not report the button for Up and Drag events, and crossterm then returns MouseButton::Left. macOS reports Ctrl plus a left click as a right click. While capture is on, the terminal’s own text selection is replaced by the application, so offer a way to turn it off

Bracketed paste

EnableBracketedPaste, DisableBracketedPaste

Delivers pasted text (crossterm’s bracketed-paste feature is on by default) as one Event::Paste(String) instead of one key event per character. Without it, pasting a long string into a search box produces hundreds of key events, and a pasted newline looks like the Enter key

Focus change

EnableFocusChange, DisableFocusChange

Delivers FocusGained and FocusLost; useful to pause animations when the window is in the background

Resize

none (always delivered)

Event::Resize(columns, rows); several can arrive in a batch, so redraw once per batch rather than once per event

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 match on the result of event::read() handles every key

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 match into functions and files; fits multi-threaded programs because channels carry the messages

Needs a main loop that keeps polling in the central place

Distributed event loops

A sub-module receives the Terminal and runs its own loop, including its own event handling

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, update, view

One model for the whole application

Small and medium applications, easy to reason about and to test as a state machine

Component architecture

A Component trait with initialisation, event handling, update and render

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(())
    })
}

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,
}

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);
    }
}

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 update whose results arrive in a later iteration.

  • Three tasks: an event task maps events to actions and sends them on an action_tx channel, 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’s Tui recipe use this shape with an Event enum (Init, Quit, Error, Tick, Render, Key, Mouse, Resize and so on) produced by one task.

flowchart LR TERM["Terminal input: crossterm EventStream"] --> ETASK["Event task: select! on the stream, a tick interval and a render interval"] ETASK -->|"Event: Key, Resize, Tick, Render, Closed"| MAIN["Main loop: select! on event_rx and action_rx"] MAIN -->|"map event to action"| ACT(("action channel")) BG["Background tasks: requests to shelfd"] -->|"Action::LoanFinished"| ACT ACT --> MAIN MAIN -->|"update(action)"| STATE["App state: books, selection, status"] MAIN -->|"Action::Render"| DRAW["terminal.draw(view)"] STATE --> DRAW DRAW --> BACKEND["Backend: cell diff to escape sequences on stdout"]

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
}

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
}

Points where the design can go wrong:

  • Do not block inside update or view. 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, as RequestLoan does.

  • Handlers of one select! run one at a time. That is what makes the mutable access to self safe, 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::Closed from a drop guard, for tasks the application cannot live without.

  • Use else => break (or an explicit Closed event) so a closed channel ends the loop instead of panicking select! 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::size and Terminal::set_cursor were deprecated in favour of Frame::area and Terminal::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

ratatui::run, ratatui::init, try_init, init_with_options

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

ratatui::restore

Restores on the normal path. Errors are printed to stderr and it does not panic; try_restore returns the error instead

Terminal::new with your own backend

Nothing: you write the hook, the pattern is take_hook, then a new hook that restores and calls the original

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(())
}

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
    }));
}

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 RawTerminal type, so the hook has to create one while the terminal is still cooked and suspend raw mode, and that its IntoAlternateScreen wrapper only leaves the alternate screen when dropped, so use ToAlternateScreen and ToMainScreen and 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 in Drop never 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) and SIGHUP (the terminal window closing) terminate the process without running the hook or any Drop, and the terminal is left raw. The Ratatui documentation does not cover signals; handle them with signal-hook or tokio::signal as 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! or eprintln! while the alternate screen is active corrupts the display. The Ratatui website has a recipe for logging with tracing to a file.

  • Decide where the output goes. Terminal::new(CrosstermBackend::new(std::io::stdout())) renders nothing visible in shelf tui | cat, while stderr would still render. The FAQ recommends stdout, which is buffered and faster, and suggests wrapping stderr in a BufWriter if it is needed; the stdout-for-data rule from Streams & Exit Codes argues for refusing to start the TUI when std::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

crossterm (default), CrosstermBackend

Windows, macOS, Linux

The default and the most used; the only backend for which ratatui::run, init and restore exist. Its Event types are described on this page

termion

termion, TermionBackend

Unix only (not available on Windows)

Has its own event API; the panic hook needs the extra steps above

termwiz

termwiz, TermwizBackend

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

termina, TerminaBackend

Not stated in the Ratatui documentation

The fourth backend in the documentation, re-exported as ratatui::termina; the documentation gives no details beyond listing it

Test backend

TestBackend

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

Fullscreen

The default for Terminal::new: the whole terminal, buffers resized automatically in draw, Frame::area() starts at (0, 0)

Inline(u16)

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

Fixed(Rect)

Draws into a fixed rectangle of the screen, for applications that control only a part of it. It is not resized automatically (call Terminal::resize), and Frame::area() returns the rectangle with its x and y offset

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 redraw = false for events that cannot change the screen (progress updates between ticks). A dirty flag set in update and cleared by draw is enough

A high frame rate

The website’s Tui recipe uses 60 frames and 4 ticks per second as defaults and the recipe’s usage example 30 frames; a text interface rarely needs more than 30, and terminals over SSH benefit from fewer. Use MissedTickBehavior::Skip for the render interval

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 BufWriter to match

Work inside draw

The closure passed to draw should only read state and build widgets. Sorting, filtering and formatting of large data belongs in update, cached in the model

Layout recomputation

The layout-cache feature (on by default) caches Layout results; do not disable it unless memory matters

Many key events from a paste

Enable bracketed paste so a large paste is one Event::Paste, not thousands of key events each followed by a redraw

Blocking the loop

A synchronous poll loop cannot react while update blocks; move the slow call to a thread or a Tokio task and report the result as a message

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 Release and Repeat as presses. Every action runs twice on Windows. Filter on KeyEventKind::Press.

  • Mixing read/poll with EventStream, 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; SIGTERM and SIGHUP still kill the process without restoring the terminal.

  • Writing to a terminal that is not one. Check is_terminal and 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’s Tui recipe 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.