The terminal is not a console
You type cargo run and expect a grid of text, a progress bar, or a menu that responds to arrow keys. Instead, you get a blinking cursor that waits for you to press Enter, and every keystroke echoes back to the shell. The terminal is fighting you. It was built for line-oriented commands, not interactive interfaces. To build a terminal UI, you have to temporarily hijack the terminal's default behavior, take direct control of the screen buffer, and manage your own input loop.
How terminal UIs actually work
Think of a standard terminal session like a conversation with a strict librarian. You speak one line at a time. The librarian waits for you to finish your sentence, repeats it back for confirmation, and then hands it to the system. If you try to interrupt mid-sentence, the librarian ignores you. Interactive UIs need a different setup. You need to turn off the librarian, grab the microphone, and start broadcasting directly to the audience.
In terminal programming, that means switching to raw mode. Raw mode disables line buffering, echo, and special character handling. Every keystroke arrives immediately as a byte. You also need to switch to the alternate screen buffer. Terminals keep two buffers: the normal one that holds your command history and scrollback, and a hidden alternate buffer meant for full-screen applications. When your app exits, the terminal swaps back to the normal buffer, leaving your shell exactly as you found it.
Claim the screen before you draw a single pixel. The terminal will not cooperate until you take control.
The minimal setup
The crossterm crate handles the low-level escape sequences and platform differences. You pair it with ratatui to draw widgets. Here is the bare minimum to claim the screen and hand it over to a renderer.
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
/// Initializes the terminal, renders a single frame, and restores the shell.
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Disable line buffering and special key handling
enable_raw_mode()?;
let mut stdout = io::stdout();
// Switch to the hidden buffer and enable mouse reporting
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
// Bridge crossterm's I/O with ratatui's rendering engine
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Your UI lives here
terminal.draw(|frame| {
// frame.render_widget(...)
})?;
// Restore the terminal to a usable state
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
Ok(())
}
Convention aside: crossterm developers always wrap setup and teardown in the same function scope. If you split them across modules, a panic in the middle leaves the user's terminal broken. Keep the lifecycle contained.
What happens under the hood
The setup sequence matters. enable_raw_mode() tells the operating system to stop buffering input. On Linux, this changes the terminal driver flags via tcsetattr. On Windows, it switches the console mode using SetConsoleMode. If you skip this, your app will freeze until the user presses Enter, and arrow keys will print garbage like ^[OA.
Next, execute! sends escape sequences to standard output. EnterAlternateScreen sends \x1b[?1049h. The terminal sees that sequence and clears the visible area, switching to the hidden buffer. EnableMouseCapture tells the terminal to report mouse clicks as byte sequences instead of letting the window manager handle them.
CrosstermBackend::new(stdout) wraps the standard output stream. ratatui needs this wrapper to translate its high-level drawing commands into the exact escape sequences crossterm understands. The Terminal::new call creates the rendering context. When you call terminal.draw, it clears the frame, runs your closure, and flushes the output. The closure receives a Frame object that tracks which parts of the screen changed, so ratatui only sends the escape sequences needed to update those regions.
The teardown is just as important. If your app crashes or exits without calling disable_raw_mode() and LeaveAlternateScreen, the user's terminal stays in raw mode. Their shell will echo every keystroke, ignore Enter, and refuse to run commands. Always restore the cursor and leave the alternate screen before returning from main.
Never assume the terminal will forgive a missing teardown. Write the cleanup path first.
Building a real loop
A static frame is not a UI. You need an event loop that listens for input, updates state, and re-renders. Here is a complete loop that handles key presses, window resizes, and graceful shutdown.
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
/// Runs the main application loop, handling input and rendering.
fn run_app() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut running = true;
let mut counter = 0u32;
while running {
// Block briefly to check for input without freezing the thread
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
// Ignore release events to avoid double-counting
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => running = false,
KeyCode::Char(' ') => counter += 1,
_ => {}
}
}
}
}
// Re-render the frame after every input check
terminal.draw(|frame| {
// frame.render_widget(...)
})?;
}
disable_raw_mode()?;
execute!(stdout, LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
run_app()
}
The event::poll call checks for pending input without blocking forever. The 100-millisecond timeout keeps the loop responsive while preventing 100% CPU usage. When poll returns true, event::read() pulls the next event from the queue. We filter for KeyEventKind::Press because modern terminals sometimes send both press and release events for the same key. Without that filter, pressing q would trigger twice.
Convention aside: ratatui developers almost always wrap the render logic in a terminal.draw closure rather than calling backend methods directly. The closure gives you a Frame that handles layout calculations and dirty-region tracking. Fighting the closure by trying to manage the backend manually defeats the purpose of the crate.
Let the frame closure handle layout math. Manual coordinate tracking is a maintenance trap.
Handling panics and cleanup
Terminal applications have a unique failure mode. If your code panics after enable_raw_mode() but before disable_raw_mode(), the process terminates immediately. The operating system does not restore terminal settings. The user is left staring at a broken shell that refuses to accept commands.
You can mitigate this by wrapping the entire app logic in a Drop guard. The guard holds a flag that tracks whether cleanup has already run. When the guard drops, it checks the flag and runs teardown if needed. This guarantees cleanup even during a panic.
struct TerminalGuard {
cleanup_run: bool,
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if !self.cleanup_run {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = Terminal::new(CrosstermBackend::new(io::stdout())).map(|mut t| t.show_cursor());
}
}
}
Convention aside: let _ = … to discard a result is a signal to readers that you considered the value and chose to drop it. In cleanup code, this is standard practice because teardown failures should not mask the original panic.
Treat the cleanup guard as a safety net. If your app crashes, the user's shell must survive.
Where things break
Terminal UIs introduce a specific set of failure modes. The most common is blocking on event::read() without checking poll first. If you call event::read() directly, it will hang until a keystroke arrives. On some platforms, this blocks the entire thread. Always pair poll with read. If you need non-blocking input, set the poll duration to zero.
If you forget to pass a mutable reference to the backend when creating the terminal, the compiler rejects you with E0308 (mismatched types). Terminal::new requires ownership of the backend, not a reference. If you try to borrow stdout instead of moving it, you'll hit E0596 (cannot borrow as mutable) because execute! needs to write to the stream.
Mouse events also trip people up. The terminal only reports mouse coordinates when you explicitly enable mouse capture. Without EnableMouseCapture, clicks register as nothing or as navigation keys. If you enable it but forget to disable it on exit, the user's shell will print raw mouse coordinates every time they move the cursor.
Another frequent issue is rendering too frequently. Calling terminal.draw inside a tight loop without an event check will flood the terminal with escape sequences. The screen will flicker, and CPU usage will spike. Only render when state changes or when a timer fires.
Respect the terminal's refresh rate. Render on demand, not on a whim.
Picking your stack
Use crossterm with ratatui when you need a full-featured terminal UI with widgets, layouts, and automatic dirty-region rendering. Use termion when you are maintaining legacy code or need a minimal dependency footprint without the overhead of a widget library. Use raw std::io with manual escape sequences when you are building a single progress bar or a simple spinner and want zero external crates. Reach for crossterm's event module when you need cross-platform key and mouse handling; the underlying APIs differ wildly between Linux, macOS, and Windows. Pick ratatui's Frame closure when you want the library to handle layout math and partial screen updates. Trust the rendering pipeline. Manual coordinate tracking is a maintenance trap.