Por que construir TUIs em Rust em 2026
Interfaces de terminal (TUI) voltaram com força. Ferramentas de desenvolvimento modernas como lazygit, bottom, gitui, yazi e television mostram que um terminal pode ser tão ergonômico quanto uma aplicação gráfica. Em Rust, essa categoria se tornou um ponto forte da linguagem: binários rápidos, binários únicos, inicialização instantânea, baixo consumo de memória e segurança de memória — tudo que importa para CLIs que rodam em servidores, containers e estações de trabalho.
O ecossistema amadureceu. ratatui é hoje a crate padrão para desenhar interfaces de terminal em Rust, substituindo a tui-rs original, descontinuada. Com o backend crossterm, a mesma base de código roda em Linux, macOS e Windows. Junto com clap para argumentos e tokio para tarefas assíncronas, é possível construir um produto completo de linha de comando com a robustez de um serviço.
Para quem busca vagas Rust e quer evoluir na carreira Rust, uma TUI é um projeto de portfólio excelente. Ela exige pensar em arquitetura, eventos assíncronos, renderização incremental, tratamento de erro e testes — exatamente as habilidades que equipes de plataforma, devtools e backend valorizam.
Os blocos: ratatui, crossterm e o loop de eventos
A separação fundamental é: ratatui desenha; crossterm lê e controla. ratatui mantém um Buffer interno com as células da tela e, no final de cada frame, calcula o diff com o frame anterior e envia apenas o que mudou para o terminal. Isso mantém a renderização rápida mesmo em terminais lentos ou via SSH.
crossterm é o backend. Ele lê eventos de teclado, mouse, redimensionamento e tamanho do terminal, e também executa comandos de controle como entrar no modo raw, esconder o cursor, limpar a tela e habilitar cores. Em 2026, crossterm é o backend mais recomendado por ser multiplataforma e por funcionar tanto síncrono quanto assíncrono sobre Tokio.
O fluxo básico de qualquer aplicação é: entrar em modo alternativo de tela e raw, capturar eventos em um canal, redesenhar a interface em cada tick, restaurar o terminal ao sair. Esse padrão se repete do hello world ao painel de monitoramento em produção.
Estrutura mínima de um app
Antes de qualquer widget, defina um App que guarda o estado. Manter o estado separado da renderização é o que torna o código testável. Um esqueleto simples:
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::time::{Duration, Instant};
pub struct App {
pub should_quit: bool,
pub counter: u64,
pub last_input: Option<String>,
}
impl App {
pub fn new() -> Self {
Self { should_quit: false, counter: 0, last_input: None }
}
pub fn tick(&mut self) {
self.counter += 1;
}
pub fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char(c) => self.last_input = Some(c.to_string()),
_ => {}
}
}
}
O App não conhece terminal. Ele só responde a tick (passagem de tempo) e handle_key (entrada). É exatamente essa pureza que permite testar sem renderizar nada. A próxima camada liga o App ao terminal.
O loop de renderização
A função main monta o terminal, roda o loop e garante que o estado do terminal seja restaurado mesmo em caso de panic ou erro:
fn main() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run(&mut terminal);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = App::new();
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(250);
loop {
terminal.draw(|frame| ui::draw(frame, &app))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
app.handle_key(key.code);
}
}
}
if last_tick.elapsed() >= tick_rate {
app.tick();
last_tick = Instant::now();
}
if app.should_quit {
break;
}
}
Ok(())
}
O segredo está no event::poll(timeout). Em vez de bloquear indefinidamente esperando teclas, o loop espera no máximo pelo tempo restante até o próximo tick. Assim a interface continua atualizando sozinha (relógios, medidores, listas que mudam) mesmo sem entrada do usuário. Esse padrão de poll + tick é a base de qualquer TUI reativa em Rust.
Layout e widgets
ratatui organiza a tela com Layout, que divide uma área retangular em sub-áreas. Cada widget (Block, Paragraph, List, Table, Gauge, Tabs, Sparkline) é desenhado em uma dessas áreas. A composição de layouts permite interfaces complexas sem acoplar desenho a estado:
mod ui {
use ratatui::{layout::{Constraint, Direction, Layout}, widgets::{Block, Borders, Paragraph}, Frame};
use crate::App;
pub fn draw(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1), Constraint::Length(3)])
.split(frame.area());
let titulo = Block::default().borders(Borders::ALL).title("Contador TUI");
frame.render_widget(Paragraph::new(format!("Ticks: {}", app.counter)).block(titulo), chunks[0]);
let corpo = Block::default().borders(Borders::ALL).title("Saída");
let texto = app.last_input.clone().unwrap_or_else(|| "Pressione uma tecla (q sai).".into());
frame.render_widget(Paragraph::new(texto), corpo);
let rodape = Block::default().borders(Borders::ALL).title("Ajuda");
frame.render_widget(Paragraph::new("q / Esc: sair"), rodape);
}
}
Aqui a função draw é uma projeção do App na tela. Ela não muta nada e não sabe nada de IO. Por isso, é fácil de isolar, refatorar e, principalmente, de testar com um TestBackend.
Eventos assíncronos com Tokio
Quando o app precisa reagir a dados externos (fila, banco, rede, arquivo crescendo), o loop síncrono vira gargalo. A solução é rodar o backend crossterm sobre Tokio e unificar eventos de teclado com eventos de domínio em um único canal:
use tokio::sync::mpsc;
use crossterm::event::{EventStream, Event};
#[derive(Debug)]
pub enum Mensagem {
Tecla(char),
Tick,
Atualizacao(String),
}
pub async fn ler_eventos(tx: mpsc::Sender<Mensagem>) -> io::Result<()> {
let mut eventos = EventStream::new();
while let Some(Ok(event)) = eventos.next().await {
if let Event::Key(key) = event {
if let KeyCode::Char(c) = key.code {
let _ = tx.send(Mensagem::Tecla(c)).await;
}
}
}
Ok(())
}
A aplicação principal vira um loop que recebe Mensagem de um canal e aplica no App. O painel redesenha a cada nova mensagem ou a cada tick. Esse padrão escala bem para dashboards que consomem métricas, monitoram logs ou consultam APIs — tudo sem travar a interface.
Com tracing, cada evento importante vira um span estruturado. Como a TUI ocupa a tela inteira no modo alternativo, os logs normais em stdout ficam invisíveis para o usuário, mas continuam disponíveis para um arquivo ou agente externo. Em produção, sempre escreva logs fora do terminal da aplicação.
Estado, imutabilidade e clareza
A armadilha clássica em TUI é misturar estado, desenho e IO na mesma função. Isso vira código difícil de manter e impossível de testar. O antídoto é manter três camadas bem separadas:
- Estado puro no
App, com métodos que recebem eventos e devolvem o próximo estado. - Renderização em funções
drawque apenas leem o estado. - IO (teclado, rede, arquivos) em tarefas que enviam mensagens para um canal.
Essa separação é o que torna o app robusto. Quando um bug aparece, você consegue reproduzi-lo com um teste de transição de estado, sem depender de terminal. Quando o design muda, você mexe só em draw. Quando um novo backend de dados entra, você adiciona uma tarefa produtora.
Testes: TestBackend e estado puro
Testar TUI é mais simples do que parece, desde que o estado esteja isolado. Para a lógica, testes normais em Rust bastam:
#[test]
fn contador_incrementa_no_tick() {
let mut app = App::new();
app.tick();
app.tick();
assert_eq!(app.counter, 2);
}
#[test]
fn tecla_q_encerra_o_app() {
let mut app = App::new();
app.handle_key(KeyCode::Char('q'));
assert!(app.should_quit);
}
Para a camada visual, ratatui oferece o TestBackend. Você desenha o App em um buffer de teste e inspeciona o conteúdo renderizado, célula por célula:
#[test]
fn renderiza_o_contador() {
let mut app = App::new();
app.tick();
let backend = ratatui::backend::TestBackend::new(30, 9);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| ui::draw(f, &app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let texto: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(texto.contains("Ticks: 1"));
}
Esse tipo de teste protege regressões visuais sem depender de terminal real. Para projetos maiores, vale gerar snapshots do buffer com crates como insta, comparando a representação textual da tela entre commits.
Tratamento de erro e restauração do terminal
Um erro fatal em uma TUI tem um custo extra: se o programa morrer no modo raw com a tela alternativa ativada, o terminal do usuário fica quebrado (sem eco de teclado, cursor invisível, layout confuso). Por isso a restauração do terminal precisa ser à prova de falhas.
A regra é nunca propagar erros para fora do main sem antes restaurar o terminal. Um padrão seguro usa um bloco que sempre executa a limpeza:
fn main() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run(&mut terminal);
let _ = disable_raw_mode();
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
if let Err(err) = &result {
eprintln!("erro: {err}");
}
result
}
Em binários maiores, considere registrar um hook de panic que restaura o terminal antes de imprimir a mensagem de pânico. Ferramentas como human-panic ajudam a mostrar mensagens amigáveis sem deixar o terminal inutilizável.
Padrões reais: listas, tabelas e atualização incremental
Apps reais costumam mostrar coleções que mudam com o tempo: linhas de log, métricas em série, itens de fila, contêineres. ratatui oferece List, Table, Sparkline e Gauge para esses casos. O segredo de desempenho é atualizar apenas os dados e deixar ratatui calcular o diff do frame.
Para listas grandes, implemente paginação virtualizada no próprio App: guarde apenas o índice visível e o número de linhas, e desenhe só a fatia relevante. Isso mantém o app fluido mesmo com milhares de itens, porque o custo de renderização depende do que aparece na tela, não do tamanho total da coleção.
Para séries temporais, mantenha um VecDeque com capacidade fixa de pontos e desenhe com Sparkline. Para tabelas, separe cabeçalho, larguras de coluna e estado de seleção. Em todos os casos, o App continua testável: você testa a lógica de “qual fatia mostrar”, não a renderização.
Quando TUI é a escolha certa
Nem toda ferramenta precisa de TUI. Para comandos rápidos de uma etapa, um CLI puro com clap e saída em texto é mais simples e mais unix-friendly. TUI brilha quando existe estado, interação contínua ou visualização de dados que mudam: dashboards, exploradores, clientes de chat, painéis de log, gerenciadores de processo, navegadores de arquivos.
O critério prático: se o usuário vai abrir a ferramenta e deixá-la rodando enquanto observa ou age, TUI vale a pena. Se o usuário executa e fecha em segundos, prefira um CLI tradicional. Escolher certo economiza semanas de manutenção e deixa o produto mais coerente com o que o usuário espera.
O portfólio de carreira
Uma TUI bem feita é um dos melhores cartões de visita para uma pessoa desenvolvedora Rust. Ela mostra domínio de arquitetura, tratamento de erro, testes, assincronia e atenção à experiência de uso. Em processos seletivos de equipes de plataforma, SRE, devtools e backend, um repositório com uma TUI limpa e testada costuma pesar mais do que muitos tutoriais.
Comece pequeno: um visualizador de uso de disco, um leitor de logs com filtros, um cliente de fila. Publique com README, testes e capturas de tela em formato textual (cargo run + cargo test). Para comparação de ergonomia entre linguagens, vale ver como a comunidade do Golang Brasil aborda CLIs — embora o modelo de TUI em Rust, com ratatui e zero-cost abstractions, siga sem equivalente direto em outras linguagens compiladas da mesma faixa de desempenho.
Checklist prático
Antes de considerar uma TUI pronta para uso sério, confira:
- o terminal é sempre restaurado, mesmo em pânico ou erro fatal;
- o estado da aplicação está isolado das funções de desenho e de IO;
- eventos de teclado, mouse e tempo chegam via um canal unificado;
- o loop usa
event::pollcom timeout para não travar a interface; - a renderização é incremental e depende apenas do estado atual;
- o backend é crossterm para portabilidade em Windows, macOS e Linux;
- listas grandes usam fatia virtualizada, não renderização completa;
- logs vão para arquivo ou tracing, nunca para o stdout ocupado pela TUI;
- há testes de transição de estado e pelo menos um teste com
TestBackend; - o README mostra como instalar, executar e sair sem deixar o terminal quebrado.
Conclusão
ratatui com crossterm tornou a construção de TUIs em Rust uma atividade previsível e agradável. A separação entre estado puro, renderização e IO — aliada ao desempenho e à segurança da linguagem — coloca Rust na dianteira dessa categoria de ferramentas. Com disciplina de arquitetura, testes reais e atenção à experiência de terminal, é possível entregar CLIs profissionais que rodam em qualquer sistema operacional, começam em milissegundos e ainda assim oferecem uma interface rica.
Para quem quer crescer como pessoa desenvolvedora Rust, esse é um dos caminhos mais completos: ele exercita async, erros, arquitetura e produto ao mesmo tempo. Comece com um app pequeno, teste o estado, publique o código e deixe que a próxima vaga ou o próximo projeto veja o que você é capaz de construir no terminal.