egui e eframe: Crie uma GUI em Rust | Rust Brasil

Crie uma interface gráfica em Rust com egui e eframe: lista de tarefas, estado, eventos, testes e cuidados para não bloquear a janela durante operações de I/O.

Para criar uma interface gráfica em Rust com egui, use eframe para abrir a janela e implemente eframe::App para desenhar os widgets e atualizar o estado. Neste tutorial, você vai construir uma lista de tarefas desktop com campo de texto, botão de inclusão, checkboxes e remoção das tarefas concluídas. Toda a interface será escrita em Rust, sem HTML ou JavaScript.

O objetivo não é escolher o melhor framework para qualquer produto. Se você ainda está decidindo entre Tauri, Iced, egui e Slint, consulte o comparativo de frameworks GUI em Rust. Aqui, vamos transformar a escolha por egui em um aplicativo pequeno, compreensível e com lógica testável.

egui e eframe: quem faz o quê?

ComponenteResponsabilidade neste projeto
eguiWidgets, layout, interação e descrição da interface
eframeJanela, loop de eventos e integração com a renderização
Sua struct ListaAppTexto em edição e coleção de tarefas
Métodos do modeloInclusão, validação e remoção de tarefas
CargoDependências, compilação, execução e testes

O egui usa immediate mode: a cada atualização da interface, você descreve o que deve aparecer a partir do estado atual. Isso não significa recriar os dados do aplicativo a cada frame, nem executar uma operação de negócio sempre que um widget é desenhado.

Por exemplo, ui.button("Adicionar").clicked() informa se houve um clique naquela atualização. A inclusão acontece dentro desse if, não toda vez que update roda. A biblioteca também mantém informações internas de interação, como foco; seu modelo de domínio continua sendo responsabilidade da aplicação.

1. Crie o projeto e fixe a versão do exemplo

Você precisa de Rust e Cargo instalados e de um ambiente desktop para abrir a janela. Crie o projeto:

cargo new lista-egui
cd lista-egui

Substitua o Cargo.toml por:

[package]
name = "lista-egui"
version = "0.1.0"
edition = "2021"

[dependencies]
eframe = "=0.30.0"

Este tutorial usa deliberadamente eframe 0.30.0, não a versão mais recente. A versão fixa acompanha a API App::update apresentada aqui e facilita comparar o exemplo com o guia de GUI do site. Versões posteriores podem mudar APIs; ao atualizar, consulte a documentação da versão escolhida em vez de misturar trechos de diferentes releases.

A crate eframe 0.30.0 declara Rust 1.80 como versão mínima, mas as dependências transitivas resolvidas hoje podem exigir uma toolchain mais nova. Use uma toolchain stable atualizada e mantenha o Cargo.lock no controle de versão do aplicativo para registrar a resolução efetiva das dependências. Uma versão fixa de eframe, sozinha, não fixa todo o grafo.

Não é necessário adicionar uma dependência direta de egui: vamos usar a reexportação eframe::egui, evitando combinar acidentalmente versões incompatíveis.

2. Implemente a lista de tarefas completa

Substitua src/main.rs pelo código abaixo:

use eframe::egui;

struct Tarefa {
    titulo: String,
    concluida: bool,
}

#[derive(Default)]
struct ListaApp {
    entrada: String,
    tarefas: Vec<Tarefa>,
}

impl ListaApp {
    fn adicionar(&mut self) {
        let titulo = self.entrada.trim().to_owned();
        if titulo.is_empty() {
            return;
        }

        self.tarefas.push(Tarefa {
            titulo,
            concluida: false,
        });
        self.entrada.clear();
    }

    fn remover_concluidas(&mut self) {
        self.tarefas.retain(|tarefa| !tarefa.concluida);
    }
}

impl eframe::App for ListaApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Minha lista de tarefas");

            ui.horizontal(|ui| {
                ui.text_edit_singleline(&mut self.entrada);
                let pode_adicionar = !self.entrada.trim().is_empty();
                if ui
                    .add_enabled(pode_adicionar, egui::Button::new("Adicionar"))
                    .clicked()
                {
                    self.adicionar();
                }
            });

            ui.separator();
            let pendentes = self.tarefas.iter()
                .filter(|tarefa| !tarefa.concluida)
                .count();
            ui.label(format!("{pendentes} tarefa(s) pendente(s)"));

            egui::ScrollArea::vertical().show(ui, |ui| {
                for tarefa in &mut self.tarefas {
                    ui.checkbox(&mut tarefa.concluida, &tarefa.titulo);
                }
            });

            ui.separator();
            if ui.button("Remover concluídas").clicked() {
                self.remover_concluidas();
            }
        });
    }
}

fn main() -> eframe::Result {
    let opcoes = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([480.0, 360.0]),
        ..Default::default()
    };

    eframe::run_native(
        "Lista de tarefas",
        opcoes,
        Box::new(|_cc| Ok(Box::new(ListaApp::default()))),
    )
}

Execute:

cargo run

A janela deve mostrar um campo vazio e o botão Adicionar desabilitado. Digite uma tarefa, clique no botão, marque o checkbox e use Remover concluídas. O contador acompanha as tarefas ainda pendentes; como ele é calculado antes dos checkboxes, uma mudança pode aparecer na atualização seguinte.

O retorno Ok(Box::new(...)) na closure de criação faz parte da API desta versão. Exemplos antigos que retornam apenas Box::new(...) podem não compilar com o manifesto acima.

Como o estado sobrevive às atualizações?

ListaApp é criada uma vez e entregue ao eframe. Seus campos continuam existindo entre chamadas de update. Já uma declaração como let mut tarefas = Vec::new() dentro de update criaria uma coleção vazia a cada chamada e perderia o histórico.

O &mut self.entrada permite que o widget de edição altere o texto. O &mut tarefa.concluida faz o mesmo com o checkbox. Isso é um uso direto de ownership e borrowing, não exige Arc<Mutex<_>> porque o exemplo manipula o modelo na thread da interface.

Observe também a separação entre percorrer a coleção e remover itens: a chamada a retain acontece depois que o loop de checkboxes terminou. Tentar remover elementos do mesmo Vec enquanto você mantém referências mutáveis a seus itens cria problemas de empréstimo e de indexação.

3. Teste a lógica sem abrir uma janela

Adicione este módulo ao final do mesmo src/main.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ignora_entrada_vazia() {
        let mut app = ListaApp {
            entrada: "   ".to_owned(),
            ..Default::default()
        };
        app.adicionar();
        assert!(app.tarefas.is_empty());
    }

    #[test]
    fn adiciona_titulo_sem_espacos_nas_bordas() {
        let mut app = ListaApp {
            entrada: "  Estudar Rust  ".to_owned(),
            ..Default::default()
        };
        app.adicionar();
        assert_eq!(app.tarefas.len(), 1);
        assert_eq!(app.tarefas[0].titulo, "Estudar Rust");
        assert!(!app.tarefas[0].concluida);
        assert!(app.entrada.is_empty());
    }

    #[test]
    fn remove_apenas_as_concluidas() {
        let mut app = ListaApp::default();
        app.entrada = "Ler documentação".to_owned();
        app.adicionar();
        app.entrada = "Praticar".to_owned();
        app.adicionar();
        app.tarefas[0].concluida = true;
        app.remover_concluidas();
        assert_eq!(app.tarefas.len(), 1);
        assert_eq!(app.tarefas[0].titulo, "Praticar");
    }
}

Rode cargo test. Esses testes validam regras do modelo, não layout, navegação por teclado ou comportamento visual. A compilação ainda precisa das dependências de GUI, mas os testes não chamam run_native nem abrem uma janela. Para ampliar a estratégia, veja o guia de testes em Rust.

4. Evite bloquear a interface com I/O

O exemplo só faz operações pequenas em memória. Quando você adicionar consulta HTTP, leitura de arquivos grandes ou processamento pesado, não coloque essas tarefas diretamente dentro de update. Essa função participa do fluxo da interface: bloqueá-la atrasa cliques, redesenho e respostas ao usuário.

Um desenho simples para trabalho bloqueante é:

  1. Ao receber um clique, marque a operação como em andamento e inicie um worker.
  2. Mova para o worker apenas os dados necessários, não toda a aplicação.
  3. Envie o resultado por um canal, incluindo falhas em um Result.
  4. No update, use try_recv, não recv, para consultar o canal sem esperar.
  5. Ao terminar, o worker chama request_repaint() em um clone do egui::Context para acordar a interface.
  6. Atualize o modelo e apresente sucesso ou erro na próxima atualização.

Evite iniciar um novo worker em cada frame. O clique deve disparar a operação uma única vez, e o estado de carregamento pode desabilitar o botão até a conclusão. O tutorial de funções em outra thread em Rust explica passagem de argumentos e posse dos dados; neste caso, não use join() dentro de update, pois ele espera pelo worker e volta a bloquear a janela.

5. Limites do exemplo e problemas frequentes

Sintoma ou necessidadeO que verificar
Erro de tipos na closure de run_nativeConfirme eframe 0.30.0 e o retorno Ok(Box::new(...))
Erro de biblioteca nativa no LinuxConfira dependências do backend e pacotes de desenvolvimento indicados pelo erro
A janela não abre em servidor ou contêinerÉ necessário acesso a uma sessão gráfica e a um backend de renderização compatível
As tarefas somem ao reiniciarO exemplo não implementa persistência
Interface congelada ao buscar dadosMova I/O bloqueante para fora de update
Texto ou seleção muda de forma inesperada em listas complexasAvalie IDs estáveis dos widgets ao reordenar e inserir itens

Para salvar tarefas, você pode serializar o modelo ou usar SQLite em aplicações Rust. Planeje o tratamento de arquivo corrompido, falha de escrita e versão do formato antes de considerar os dados duráveis.

Para entregar o aplicativo a outras pessoas, cargo build --release é apenas o começo. Teste o binário no sistema-alvo, confira dependências nativas e planeje instaladores, assinatura e atualizações. A possibilidade de executar egui na web não torna este projeto automaticamente WebAssembly: a integração web exige entrada e configuração próprias.

Perguntas frequentes

egui cria widgets nativos do sistema operacional?

Não no sentido de usar os controles padrão de Windows, macOS ou GTK para cada botão e campo. O egui desenha seus widgets. Se aparência nativa, leitores de tela ou integrações específicas forem requisitos, valide esses pontos no sistema-alvo antes de escolher o toolkit.

Preciso de Tokio para usar eframe?

Não. A lista usa apenas eframe e a biblioteca padrão. Tokio pode entrar para operações assíncronas, mas exige uma integração deliberada com o loop da interface; adicionar o runtime não torna código bloqueante seguro dentro de update.

Quando este tipo de GUI faz sentido?

Para ferramentas internas, visualizadores, editores técnicos e protótipos em que uma interface escrita em Rust simplifica o projeto. Para uma equipe que já domina frontend web ou um produto cheio de componentes web, avalie também Tauri versus Electron.

Próximo passo

Antes de aumentar a aplicação, faça o ciclo completo: adicionar, concluir, remover e testar. Depois escolha uma evolução concreta, como persistência ou importação em background. Assim você pratica estado, empréstimos e separação entre domínio e interface sem transformar a primeira janela em uma arquitetura difícil de manter.

Se ainda precisa consolidar a linguagem antes de avançar no desktop, siga a trilha de aprendizado ou conheça o curso de Rust.

Referências da versão utilizada