TUI apps com Ratatui: interfaces no terminal

1. Introdução ao Ratatui e TUI em Rust

Terminal User Interfaces (TUIs) oferecem uma alternativa elegante para aplicações que não necessitam de interfaces gráficas completas. Em Rust, o ecossistema para construção de TUIs é maduro, com destaque para o Ratatui — um fork ativo do popular tui-rs. Diferente de bibliotecas como Cursive (orientada a callbacks) ou termion (baixo nível), o Ratatui adota uma abordagem reativa e declarativa, similar a frameworks web modernos.

Para iniciar, crie um novo projeto:

cargo new meu-app-tui --name meu_app_tui
cd meu-app-tui

Adicione as dependências no Cargo.toml:

[dependencies]
ratatui = "0.26"
crossterm = "0.27"

O crossterm será responsável pelo gerenciamento do terminal (modo raw, eventos de teclado).

2. Primeiros Passos: Seu Primeiro App com Ratatui

Vamos construir um app mínimo que exibe "Olá, Ratatui!":

use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Alignment, Rect},
    widgets::{Block, Borders, Paragraph},
    Frame, Terminal,
};
use std::io::{stdout, Write};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    enable_raw_mode()?;
    let mut stdout = stdout();
    execute!(stdout, EnterAlternateScreen)?;

    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = run_app(&mut terminal);

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;

    result
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
    loop {
        terminal.draw(|f| {
            let area = f.size();
            let block = Block::default()
                .title("Meu App")
                .borders(Borders::ALL);
            let paragraph = Paragraph::new("Olá, Ratatui!")
                .block(block)
                .alignment(Alignment::Center);
            f.render_widget(paragraph, area);
        })?;

        if let Event::Key(key) = event::read()? {
            if key.code == KeyCode::Char('q') {
                return Ok(());
            }
        }
    }
}

A estrutura é simples: um loop de eventos que alterna entre renderização (terminal.draw) e captura de entrada. O widget Paragraph é envolvido por um Block com bordas.

3. Layout e Organização Visual

Para criar interfaces mais complexas, use o Layout com Constraint:

use ratatui::layout::{Constraint, Direction, Layout};

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
    loop {
        terminal.draw(|f| {
            let area = f.size();
            let chunks = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
                .split(area);

            let left_block = Block::default()
                .title("Painel Lateral")
                .borders(Borders::ALL);
            let right_block = Block::default()
                .title("Área Principal")
                .borders(Borders::ALL);

            f.render_widget(left_block, chunks[0]);
            f.render_widget(right_block, chunks[1]);
        })?;

        if let Event::Key(key) = event::read()? {
            if key.code == KeyCode::Char('q') {
                return Ok(());
            }
        }
    }
}

Constraint permite distribuir espaço usando porcentagens, proporções (Ratio), tamanhos fixos (Length) ou mínimo/máximo (Min, Max). É possível aninhar layouts para criar grids complexos.

4. Widgets Interativos e Navegação

Lista de seleção

use ratatui::widgets::{List, ListItem, ListState};

let items = vec!["Item A", "Item B", "Item C"];
let list_items: Vec<ListItem> = items.iter().map(|i| ListItem::new(*i)).collect();
let list = List::new(list_items).block(Block::default().title("Menu").borders(Borders::ALL));

let mut list_state = ListState::default();
list_state.select(Some(0));
f.render_stateful_widget(list, chunks[0], &mut list_state);

Tabela

use ratatui::widgets::{Row, Table};

let rows = vec![
    Row::new(vec!["1", "Alice", "Engenharia"]),
    Row::new(vec!["2", "Bob", "Design"]),
];
let table = Table::new(rows)
    .header(Row::new(vec!["ID", "Nome", "Departamento"]).style(Style::default().add_modifier(Modifier::BOLD)))
    .block(Block::default().title("Funcionários").borders(Borders::ALL))
    .widths(&[Constraint::Length(5), Constraint::Length(15), Constraint::Length(15)]);

Abas (Tabs)

use ratatui::widgets::{Tabs, TabsState};

let titles = vec!["Home", "Config", "Ajuda"];
let tabs = Tabs::new(titles)
    .block(Block::default().borders(Borders::ALL))
    .highlight_style(Style::default().bg(Color::Blue));

let mut tabs_state = TabsState::new(vec!["Home", "Config", "Ajuda"]);
tabs_state.selected = 0;
f.render_widget(tabs, chunks[0]);

5. Input do Usuário e Eventos

O crossterm captura eventos de forma não bloqueante:

use crossterm::event::{poll, read, Event, KeyCode, KeyEvent};
use std::time::Duration;

fn handle_input(state: &mut AppState) -> Result<(), Box<dyn std::error::Error>> {
    if poll(Duration::from_millis(100))? {
        match read()? {
            Event::Key(KeyEvent { code, .. }) => match code {
                KeyCode::Up => state.focus_previous(),
                KeyCode::Down => state.focus_next(),
                KeyCode::Enter => state.select_current(),
                KeyCode::Char('q') => state.quit = true,
                _ => {}
            },
            Event::Resize(_, _) => state.needs_redraw = true,
            _ => {}
        }
    }
    Ok(())
}

Para gerenciar múltiplas telas ou contextos, use um enum de estado:

enum Focus {
    Menu,
    Content,
    Help,
}

struct AppState {
    focus: Focus,
    selected_item: usize,
    quit: bool,
}

6. Estado, Atualização e Ciclo de Vida

O padrão MVU (Model-View-Update) simplifica o gerenciamento:

struct Model {
    counter: i32,
    items: Vec<String>,
}

enum Message {
    Increment,
    Decrement,
    AddItem(String),
    Quit,
}

fn update(model: &mut Model, msg: Message) {
    match msg {
        Message::Increment => model.counter += 1,
        Message::Decrement => model.counter -= 1,
        Message::AddItem(name) => model.items.push(name),
        Message::Quit => std::process::exit(0),
    }
}

fn view<B: Backend>(f: &mut Frame<B>, model: &Model) {
    // Renderização baseada no modelo
}

Para atualizações em tempo real, use std::thread::sleep ou timers:

use std::time::{Duration, Instant};

let start = Instant::now();
loop {
    let elapsed = start.elapsed().as_secs();
    // Atualiza modelo com base no tempo
    terminal.draw(|f| render_with_time(f, elapsed))?;
    std::thread::sleep(Duration::from_millis(50));
}

7. Estilização e Temas

O Style permite controle fino sobre cores e modificadores:

use ratatui::style::{Color, Modifier, Style};

let title_style = Style::default()
    .fg(Color::Cyan)
    .bg(Color::Black)
    .add_modifier(Modifier::BOLD);

let selected_style = Style::default()
    .fg(Color::White)
    .bg(Color::Blue);

let error_style = Style::default()
    .fg(Color::Red)
    .add_modifier(Modifier::RAPID_BLINK);

Para temas customizados, crie uma struct centralizada:

struct Theme {
    primary: Style,
    secondary: Style,
    error: Style,
    success: Style,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            primary: Style::default().fg(Color::Cyan),
            secondary: Style::default().fg(Color::Magenta),
            error: Style::default().fg(Color::Red),
            success: Style::default().fg(Color::Green),
        }
    }
}

O Ratatui detecta automaticamente a capacidade de cores do terminal (16, 256 ou truecolor) usando crossterm::style::terminal_supports_colors.

8. Boas Práticas e Publicação

Testes com TestBackend

use ratatui::backend::TestBackend;

#[test]
fn test_render() {
    let backend = TestBackend::new(80, 24);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal.draw(|f| {
        let paragraph = Paragraph::new("Teste");
        f.render_widget(paragraph, f.size());
    }).unwrap();
    let buffer = terminal.backend().buffer();
    // Verificar conteúdo do buffer
    assert_eq!(buffer.get(0, 0).symbol, "T");
}

Tratamento de redimensionamento

O Event::Resize(width, height) permite reagir a mudanças de tamanho. Em loops de renderização, use f.size() sempre para obter as dimensões atuais.

Dicas de performance

  • Use render_widget em vez de render_stateful_widget quando não há estado interno.
  • Evite alocações desnecessárias no loop de renderização — pré-calcule listas e estilos.
  • Para listas grandes, considere List com scroll virtual (apenas itens visíveis).

Referências