Build Cross-Platform TUI Applications with Rust and Ratatui in 2025
Build Cross-Platform TUI Applications with Rust and Ratatui in 2025
Terminal User Interfaces (TUIs) are experiencing a genuine resurgence. While Electron dominated the last decade with memory-heavy desktop applications, developers increasingly recognize the friction: bloated dependency trees, poor performance on resource-constrained systems, and inconsistent native integration across platforms.
If you're evaluating a shift from Electron-based tools to lightweight TUIs, this guide walks you through building your first cross-platform terminal application using Rust and Ratatui—the Rust ecosystem's most mature TUI framework.
Why TUIs Are Becoming Viable Again
The original problem with native GUI development hasn't disappeared. Windows continues cycling through GUI frameworks (Winforms → WPF → WinUI → MAUI), Linux fragments across GTK and Qt implementations, and macOS increasingly violates its own design guidelines. Electron emerged as a pragmatic solution: "just ship Chromium," the thinking went.
But that pragmatism carries costs. A typical Electron app consumes 400-800MB RAM before doing meaningful work. For server-side tools, deployment infrastructure, or applications targeting developer workflows, these costs became unjustifiable.
Meanwhile, the terminal ecosystem matured. Rich markup support, Unicode rendering, 24-bit color, and mouse event handling transformed what terminal applications could accomplish. Combined with Rust's performance characteristics and cross-platform story, TUIs became genuinely competitive.
Setting Up Your Development Environment
Before writing code, ensure you have Rust 1.70+ installed:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update
Create a new project:
cargo new --bin my-tui-app
cd my-tui-app
Add Ratatui and its dependencies to Cargo.toml:
[dependencies]
ratatui = "0.27"
crossterm = "0.28"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Why these specific crates?
- Ratatui: The TUI rendering framework. Handles layout, widgets, and terminal abstractions
- Crossterm: Cross-platform terminal backend handling input and output
- Tokio: Async runtime for non-blocking event handling
- Serde + serde_json: For configuration and data persistence
Your First Application: A Task Manager
Let's build a minimal but functional task manager demonstrating core TUI patterns:
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAltScreen, LeaveAltScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
widgets::{Block, Borders, List, ListItem, Paragraph},
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
Frame,
};
use std::io;
struct App {
tasks: Vec<String>,
selected: usize,
input: String,
}
impl App {
fn new() -> Self {
App {
tasks: vec![
"Learn Ratatui".to_string(),
"Build first TUI".to_string(),
],
selected: 0,
input: String::new(),
}
}
fn add_task(&mut self) {
if !self.input.is_empty() {
self.tasks.push(self.input.clone());
self.input.clear();
}
}
fn next(&mut self) {
self.selected = (self.selected + 1) % self.tasks.len();
}
fn previous(&mut self) {
if self.selected > 0 {
self.selected -= 1;
} else {
self.selected = self.tasks.len() - 1;
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAltScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| ui(f, &app))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Up => app.previous(),
KeyCode::Down => app.next(),
KeyCode::Char(c) => app.input.push(c),
KeyCode::Enter => app.add_task(),
KeyCode::Backspace => { app.input.pop(); },
_ => {},
}
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAltScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(f.size());
let items: Vec<ListItem> = app
.tasks
.iter()
.enumerate()
.map(|(i, task)| {
let style = if i == app.selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
ListItem::new(task.as_str()).style(style)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Tasks"))
.style(Style::default().fg(Color::White));
f.render_widget(list, chunks[0]);
let input = Paragraph::new(app.input.as_str())
.block(Block::default().borders(Borders::ALL).title("Add Task"));
f.render_widget(input, chunks[1]);
}
Key Architectural Decisions
Event Handling Pattern
The example above uses synchronous event reading. For production applications, implement the async pattern with tokio channels:
use tokio::sync::mpsc;
enum Message {
Input(KeyCode),
Tick,
}
async fn event_loop(tx: mpsc::Sender<Message>) {
loop {
if let Ok(Event::Key(key)) = event::read() {
tx.send(Message::Input(key.code)).await.ok();
}
}
}
This prevents UI blocking during I/O operations.
Cross-Platform Considerations
| Consideration | Windows | macOS | Linux | |---|---|---|---| | Terminal detection | Automatic via Crossterm | Works in iTerm2, Terminal.app | Works in most terminals | | Mouse support | Full support | Full support | Full support | | 24-bit color | Windows 10+ required | Native | Native | | Raw mode | Requires Windows 10+ | Native | Native | | UTF-8 rendering | Enable via chcp 65001 | Native | Native |
For maximum compatibility, test against actual terminals and provide fallback styling for limited environments.
State Management Strategies
As your TUI grows, you'll need predictable state management. Consider the Redux/Elm pattern:
enum Action {
AddTask(String),
DeleteTask(usize),
SelectNext,
SelectPrevious,
}
fn reducer(app: &mut App, action: Action) {
match action {
Action::AddTask(task) => app.tasks.push(task),
Action::DeleteTask(idx) => { app.tasks.remove(idx); },
Action::SelectNext => app.next(),
Action::SelectPrevious => app.previous(),
}
}
This separates business logic from rendering and makes testing trivial.
Performance Optimization
Unlike Electron apps that inherently consume significant resources, TUIs can run on constrained systems. However, optimize deliberately:
- Redraw selectively: Use Ratatui's
StatefulWidgetto cache expensive computations - Buffer I/O: Read configuration once at startup, not per frame
- Profile memory:
valgrind --tool=massiffor heap analysis - Batch rendering: Accumulate updates in batches rather than individual redraws
Migration Path from Electron
If you're evaluating whether to migrate an existing Electron app, ask:
- Does your application primarily serve CLI/developer workflows? (Strong TUI candidate)
- Do you require complex animations or rich media? (Keep Electron)
- Are your users on resource-constrained infrastructure? (Migrate to TUI)
- Do you need the same binary across Windows, macOS, and Linux? (Rust + Ratatui excels)
Phased migrations work well: build new features in Ratatui, keep legacy Electron components behind feature flags, gradually deprecate.
Testing Your TUI Application
Terminal interactions are testable. Mock the terminal backend:
use ratatui::backend::TestBackend;
#[test]
fn test_task_addition() {
let mut app = App::new();
app.input = "New task".to_string();
app.add_task();
assert_eq!(app.tasks.len(), 3);
assert_eq!(app.input, "");
}
Conclusion
Building TUI applications with Rust and Ratatui offers genuine advantages: single-digit memory footprints, instant startup, and zero runtime dependencies. The ecosystem has matured to support serious applications, from DevOps tools to productivity software.
Start small, use the patterns outlined here, and iterate. The investment in learning Ratatui pays dividends in application performance and user experience.
Recommended Tools
- DigitalOceanSimplicity in the cloud
- SupabaseThe open source Firebase alternative