---
title: "TUI em Rust com Ratatui e Crossterm: interfaces de terminal modernas em 2026"
url: "https://rustlang.com.br/blog/rust-tui-ratatui-crossterm-terminal-2026/"
markdown_url: "https://rustlang.com.br/blog/rust-tui-ratatui-crossterm-terminal-2026.MD"
description: "Como construir interfaces de terminal (TUI) em Rust com ratatui e crossterm em 2026: arquitetura, eventos, estado, layout, testes e padrões de produção para CLIs profissionais."
date: "2026-06-26"
author: "Equipe Rust Brasil"
---

# TUI em Rust com Ratatui e Crossterm: interfaces de terminal modernas em 2026

Como construir interfaces de terminal (TUI) em Rust com ratatui e crossterm em 2026: arquitetura, eventos, estado, layout, testes e padrões de produção para CLIs profissionais.


## 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](/ecossistema/clap/) para argumentos e [tokio](/ecossistema/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](/vagas/) e quer evoluir na [carreira Rust](/carreira/), 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:

```rust
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:

```rust
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:

```rust
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:

```rust
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](/ecossistema/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:

1. **Estado puro** no `App`, com métodos que recebem eventos e devolvem o próximo estado.
2. **Renderização** em funções `draw` que apenas leem o estado.
3. **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:

```rust
#[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:

```rust
#[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:

```rust
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](/ecossistema/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 <a href="https://golang.com.br/" target="_blank" rel="noopener noreferrer" onclick="umami.track('portfolio-site-click', { destination: 'golang.com.br' })">Golang Brasil</a> 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::poll` com 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.
