Neste projeto vamos construir um editor de texto minimalista para o terminal, inspirado no nano, usando Rust e a crate crossterm. O editor permite abrir arquivos, navegar pelo conteúdo com as teclas de seta, inserir e apagar caracteres, exibir números de linha, realizar buscas com Ctrl+F e salvar alterações com Ctrl+S. Tudo isso funcionando diretamente no terminal, sem interface gráfica.
Este é um projeto que exercita profundamente o gerenciamento de estado em Rust: manter um buffer de texto editável, sincronizar a posição do cursor com a janela de visualização, lidar com entrada de teclado em modo raw e renderizar a tela completa a cada alteração. Ao final, você terá um editor funcional que pode usar no dia a dia para edições rápidas.
O Que Vamos Construir
Um editor de texto com as seguintes funcionalidades:
- Abertura de arquivos existentes passados como argumento
- Criação de novos arquivos do zero
- Movimentação do cursor com setas (cima, baixo, esquerda, direita)
- Teclas Home, End, Page Up e Page Down
- Inserção de caracteres e novas linhas (Enter)
- Exclusão com Backspace e Delete
- Números de linha na margem esquerda
- Barra de status com nome do arquivo, posição e indicador de modificação
- Barra de mensagens para feedback ao usuário
- Busca de texto com Ctrl+F (navega entre ocorrências)
- Salvamento com Ctrl+S
- Rolagem vertical e horizontal automática
Estrutura do Projeto
editor-texto/
├── Cargo.toml
└── src/
└── main.rs
Configurando o Projeto
Crie o projeto com o Cargo:
cargo new editor-texto
cd editor-texto
Edite o Cargo.toml:
[package]
name = "editor-texto"
version = "0.1.0"
edition = "2021"
[dependencies]
crossterm = "0.28"
Usamos apenas crossterm para controle do terminal (modo raw, eventos de teclado, posicionamento do cursor e estilos). Todo o restante é implementado com a biblioteca padrão.
Passo 1: Estruturas de Dados do Editor
Vamos definir as estruturas que mantêm o estado completo do editor: o buffer de texto, a posição do cursor, a janela de visualização (scroll) e os metadados do arquivo.
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute, queue,
style::{self, Attribute, Stylize},
terminal::{self, ClearType},
};
use std::env;
use std::fs;
use std::io::{self, Write};
use std::time::{Duration, Instant};
/// Largura da coluna de números de linha
const LARGURA_NUMEROS: usize = 5;
/// Estado completo do editor
struct Editor {
/// Linhas do documento (cada linha é um Vec de caracteres)
linhas: Vec<Vec<char>>,
/// Posição do cursor no documento (coluna, linha)
cursor_x: usize,
cursor_y: usize,
/// Offset de rolagem (qual linha/coluna está no topo-esquerdo da tela)
scroll_y: usize,
scroll_x: usize,
/// Dimensões do terminal
largura_tela: usize,
altura_tela: usize,
/// Nome do arquivo aberto
nome_arquivo: Option<String>,
/// Indica se o documento foi modificado desde o último salvamento
modificado: bool,
/// Mensagem exibida na barra inferior
mensagem: Option<(String, Instant)>,
/// Indica se o editor deve encerrar
encerrar: bool,
}
impl Editor {
/// Cria um novo editor vazio
fn novo() -> Self {
let (largura, altura) = terminal::size().unwrap_or((80, 24));
Editor {
linhas: vec![Vec::new()], // Começa com uma linha vazia
cursor_x: 0,
cursor_y: 0,
scroll_y: 0,
scroll_x: 0,
largura_tela: largura as usize,
altura_tela: altura as usize - 2, // Reserva 2 linhas (status + mensagem)
nome_arquivo: None,
modificado: false,
mensagem: None,
encerrar: false,
}
}
/// Abre um arquivo existente para edição
fn abrir_arquivo(&mut self, caminho: &str) -> io::Result<()> {
let conteudo = fs::read_to_string(caminho)?;
self.linhas = conteudo
.lines()
.map(|linha| linha.chars().collect())
.collect();
// Garante que existe ao menos uma linha
if self.linhas.is_empty() {
self.linhas.push(Vec::new());
}
self.nome_arquivo = Some(caminho.to_string());
self.modificado = false;
self.definir_mensagem(&format!("Arquivo '{}' aberto ({} linhas)", caminho, self.linhas.len()));
Ok(())
}
/// Define uma mensagem temporária na barra inferior
fn definir_mensagem(&mut self, texto: &str) {
self.mensagem = Some((texto.to_string(), Instant::now()));
}
/// Retorna o comprimento da linha atual
fn comprimento_linha_atual(&self) -> usize {
self.linhas[self.cursor_y].len()
}
/// Área editável disponível (descontando a coluna de números)
fn largura_editavel(&self) -> usize {
self.largura_tela.saturating_sub(LARGURA_NUMEROS + 1)
}
}
Cada linha é armazenada como Vec<char> em vez de String para facilitar inserções e exclusões em posições arbitrárias – acessar e modificar o caractere na posição N é O(1) em vez de O(N) como seria com bytes UTF-8. A rolagem é controlada por scroll_y e scroll_x, que indicam qual porção do documento está visível na tela.
Passo 2: Movimentação do Cursor e Edição
Agora implementamos todas as operações de movimentação e edição de texto: mover o cursor, inserir caracteres, apagar com Backspace/Delete e criar novas linhas.
impl Editor {
/// Move o cursor para cima
fn mover_cima(&mut self) {
if self.cursor_y > 0 {
self.cursor_y -= 1;
// Ajusta a coluna se a linha de cima for mais curta
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x > comprimento {
self.cursor_x = comprimento;
}
}
}
/// Move o cursor para baixo
fn mover_baixo(&mut self) {
if self.cursor_y < self.linhas.len() - 1 {
self.cursor_y += 1;
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x > comprimento {
self.cursor_x = comprimento;
}
}
}
/// Move o cursor para a esquerda
fn mover_esquerda(&mut self) {
if self.cursor_x > 0 {
self.cursor_x -= 1;
} else if self.cursor_y > 0 {
// Volta para o final da linha anterior
self.cursor_y -= 1;
self.cursor_x = self.linhas[self.cursor_y].len();
}
}
/// Move o cursor para a direita
fn mover_direita(&mut self) {
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x < comprimento {
self.cursor_x += 1;
} else if self.cursor_y < self.linhas.len() - 1 {
// Avança para o início da próxima linha
self.cursor_y += 1;
self.cursor_x = 0;
}
}
/// Move o cursor para o início da linha
fn mover_inicio(&mut self) {
self.cursor_x = 0;
}
/// Move o cursor para o final da linha
fn mover_final(&mut self) {
self.cursor_x = self.linhas[self.cursor_y].len();
}
/// Move uma página para cima
fn pagina_cima(&mut self) {
if self.cursor_y > self.altura_tela {
self.cursor_y -= self.altura_tela;
} else {
self.cursor_y = 0;
}
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x > comprimento {
self.cursor_x = comprimento;
}
}
/// Move uma página para baixo
fn pagina_baixo(&mut self) {
self.cursor_y = (self.cursor_y + self.altura_tela).min(self.linhas.len() - 1);
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x > comprimento {
self.cursor_x = comprimento;
}
}
/// Insere um caractere na posição do cursor
fn inserir_caractere(&mut self, c: char) {
self.linhas[self.cursor_y].insert(self.cursor_x, c);
self.cursor_x += 1;
self.modificado = true;
}
/// Insere uma nova linha (Enter)
fn inserir_nova_linha(&mut self) {
// Divide a linha atual na posição do cursor
let restante: Vec<char> = self.linhas[self.cursor_y].drain(self.cursor_x..).collect();
self.cursor_y += 1;
self.linhas.insert(self.cursor_y, restante);
self.cursor_x = 0;
self.modificado = true;
}
/// Apaga o caractere antes do cursor (Backspace)
fn apagar_atras(&mut self) {
if self.cursor_x > 0 {
self.cursor_x -= 1;
self.linhas[self.cursor_y].remove(self.cursor_x);
self.modificado = true;
} else if self.cursor_y > 0 {
// Junta a linha atual com a anterior
let linha_atual = self.linhas.remove(self.cursor_y);
self.cursor_y -= 1;
self.cursor_x = self.linhas[self.cursor_y].len();
self.linhas[self.cursor_y].extend(linha_atual);
self.modificado = true;
}
}
/// Apaga o caractere na posição do cursor (Delete)
fn apagar_frente(&mut self) {
let comprimento = self.linhas[self.cursor_y].len();
if self.cursor_x < comprimento {
self.linhas[self.cursor_y].remove(self.cursor_x);
self.modificado = true;
} else if self.cursor_y < self.linhas.len() - 1 {
// Junta a próxima linha com a atual
let proxima_linha = self.linhas.remove(self.cursor_y + 1);
self.linhas[self.cursor_y].extend(proxima_linha);
self.modificado = true;
}
}
/// Ajusta a rolagem para manter o cursor visível
fn ajustar_scroll(&mut self) {
// Rolagem vertical
if self.cursor_y < self.scroll_y {
self.scroll_y = self.cursor_y;
}
if self.cursor_y >= self.scroll_y + self.altura_tela {
self.scroll_y = self.cursor_y - self.altura_tela + 1;
}
// Rolagem horizontal
let largura = self.largura_editavel();
if self.cursor_x < self.scroll_x {
self.scroll_x = self.cursor_x;
}
if self.cursor_x >= self.scroll_x + largura {
self.scroll_x = self.cursor_x - largura + 1;
}
}
}
A movimentação do cursor lida com todos os casos limites: ao se mover para a esquerda no início de uma linha, o cursor salta para o final da linha anterior; ao se mover para baixo em uma linha mais longa, a coluna é ajustada para não ultrapassar o comprimento da nova linha. O Backspace no início de uma linha junta as duas linhas, e o Delete no final faz o mesmo com a próxima.
Passo 3: Busca e Salvamento
Implementamos a funcionalidade de busca (Ctrl+F), que permite digitar um termo e navegar entre as ocorrências, e o salvamento (Ctrl+S).
impl Editor {
/// Salva o arquivo atual
fn salvar(&mut self) -> io::Result<()> {
let caminho = match &self.nome_arquivo {
Some(c) => c.clone(),
None => {
// Pede o nome do arquivo ao usuário
match self.ler_prompt("Salvar como: ") {
Some(nome) => {
self.nome_arquivo = Some(nome.clone());
nome
}
None => {
self.definir_mensagem("Salvamento cancelado.");
return Ok(());
}
}
}
};
// Converte as linhas para texto
let conteudo: String = self.linhas
.iter()
.map(|linha| linha.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n");
fs::write(&caminho, &conteudo)?;
let bytes = conteudo.len();
self.modificado = false;
self.definir_mensagem(&format!(
"Salvo '{}' ({} linhas, {} bytes)",
caminho,
self.linhas.len(),
bytes
));
Ok(())
}
/// Realiza busca interativa no documento
fn buscar(&mut self) {
let termo = match self.ler_prompt("Buscar: ") {
Some(t) if !t.is_empty() => t,
_ => {
self.definir_mensagem("Busca cancelada.");
return;
}
};
// Busca a partir da posição atual do cursor
let mut encontrou = false;
for y in self.cursor_y..self.linhas.len() {
let texto: String = self.linhas[y].iter().collect();
let inicio_busca = if y == self.cursor_y {
self.cursor_x + 1
} else {
0
};
if let Some(pos) = texto[inicio_busca..].find(&termo) {
self.cursor_y = y;
self.cursor_x = inicio_busca + pos;
encontrou = true;
self.definir_mensagem(&format!(
"Encontrado na linha {}. Ctrl+F para próximo.",
y + 1
));
break;
}
}
// Se não encontrou a partir da posição atual, volta ao início
if !encontrou {
for y in 0..=self.cursor_y {
let texto: String = self.linhas[y].iter().collect();
let limite = if y == self.cursor_y {
self.cursor_x
} else {
texto.len()
};
if let Some(pos) = texto[..limite].find(&termo) {
self.cursor_y = y;
self.cursor_x = pos;
encontrou = true;
self.definir_mensagem(&format!(
"Encontrado na linha {} (voltou ao início).",
y + 1
));
break;
}
}
}
if !encontrou {
self.definir_mensagem(&format!("'{}' não encontrado.", termo));
}
}
/// Lê uma entrada do usuário na barra de mensagens (para busca e nome de arquivo)
fn ler_prompt(&mut self, prompt: &str) -> Option<String> {
let mut entrada = String::new();
let mut stdout = io::stdout();
loop {
// Desenha o prompt na linha de mensagem
let y_msg = self.altura_tela as u16 + 1;
queue!(
stdout,
cursor::MoveTo(0, y_msg),
terminal::Clear(ClearType::CurrentLine),
style::PrintStyledContent(prompt.bold()),
style::Print(&entrada),
cursor::Show,
)
.unwrap();
stdout.flush().unwrap();
// Lê evento de teclado
if event::poll(Duration::from_millis(100)).unwrap() {
if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
match code {
KeyCode::Enter => return Some(entrada),
KeyCode::Esc => return None,
KeyCode::Backspace => {
entrada.pop();
}
KeyCode::Char(c) => {
entrada.push(c);
}
_ => {}
}
}
}
}
}
}
A busca implementa wraparound: se o termo não for encontrado entre a posição do cursor e o final do documento, a busca recomeça do início. Isso permite que o usuário pressione Ctrl+F repetidamente para navegar por todas as ocorrências de forma cíclica. O ler_prompt reutiliza a barra de mensagens como campo de entrada, criando uma interface similar ao nano.
Passo 4: Renderização e Loop Principal
Finalmente, implementamos a renderização da tela e o loop principal que une tudo.
impl Editor {
/// Renderiza o estado completo do editor no terminal
fn renderizar(&mut self, stdout: &mut io::Stdout) {
self.ajustar_scroll();
queue!(stdout, cursor::Hide, cursor::MoveTo(0, 0)).unwrap();
// Renderiza cada linha visível
for i in 0..self.altura_tela {
let linha_doc = self.scroll_y + i;
// Limpa a linha
queue!(stdout, terminal::Clear(ClearType::CurrentLine)).unwrap();
if linha_doc < self.linhas.len() {
// Número da linha (com padding à direita)
let numero = format!("{:>4} ", linha_doc + 1);
queue!(stdout, style::PrintStyledContent(numero.dark_grey())).unwrap();
// Conteúdo da linha (com rolagem horizontal)
let texto: String = self.linhas[linha_doc].iter().collect();
let largura = self.largura_editavel();
if self.scroll_x < texto.len() {
let fim = (self.scroll_x + largura).min(texto.len());
let visivel = &texto[self.scroll_x..fim];
queue!(stdout, style::Print(visivel)).unwrap();
}
} else {
// Linhas além do documento: mostra til (~)
queue!(stdout, style::PrintStyledContent("~".dark_grey())).unwrap();
}
// Nova linha (exceto na última)
if i < self.altura_tela - 1 {
queue!(stdout, style::Print("\r\n")).unwrap();
}
}
// Barra de status (linha penúltima)
queue!(stdout, style::Print("\r\n")).unwrap();
self.renderizar_barra_status(stdout);
// Barra de mensagens (última linha)
queue!(stdout, style::Print("\r\n")).unwrap();
self.renderizar_barra_mensagem(stdout);
// Posiciona o cursor na tela
let cursor_tela_x = (LARGURA_NUMEROS + self.cursor_x - self.scroll_x) as u16;
let cursor_tela_y = (self.cursor_y - self.scroll_y) as u16;
queue!(
stdout,
cursor::MoveTo(cursor_tela_x, cursor_tela_y),
cursor::Show,
)
.unwrap();
stdout.flush().unwrap();
}
/// Renderiza a barra de status com informações do arquivo
fn renderizar_barra_status(&self, stdout: &mut io::Stdout) {
queue!(stdout, terminal::Clear(ClearType::CurrentLine)).unwrap();
let nome = self.nome_arquivo.as_deref().unwrap_or("[Sem nome]");
let modificado = if self.modificado { " [Modificado]" } else { "" };
let lado_esquerdo = format!(" {}{}", nome, modificado);
let lado_direito = format!(
"Ln {}, Col {} | {} linhas ",
self.cursor_y + 1,
self.cursor_x + 1,
self.linhas.len()
);
// Preenche o espaço entre os lados com espaços
let espacos = self.largura_tela
.saturating_sub(lado_esquerdo.len() + lado_direito.len());
let barra = format!(
"{}{}{}",
lado_esquerdo,
" ".repeat(espacos),
lado_direito
);
queue!(
stdout,
style::PrintStyledContent(
barra
.clone()
.with(style::Color::Black)
.on(style::Color::White)
.attribute(Attribute::Bold)
),
)
.unwrap();
}
/// Renderiza a barra de mensagens
fn renderizar_barra_mensagem(&mut self, stdout: &mut io::Stdout) {
queue!(stdout, terminal::Clear(ClearType::CurrentLine)).unwrap();
// Mostra a mensagem se existir e se foi definida há menos de 5 segundos
if let Some((ref texto, instante)) = self.mensagem {
if instante.elapsed() < Duration::from_secs(5) {
let msg = if texto.len() > self.largura_tela {
&texto[..self.largura_tela]
} else {
texto
};
queue!(stdout, style::Print(msg)).unwrap();
} else {
self.mensagem = None;
// Mostra atalhos padrão
queue!(
stdout,
style::PrintStyledContent(
" Ctrl+S: Salvar | Ctrl+F: Buscar | Ctrl+Q: Sair".dark_grey()
)
)
.unwrap();
}
} else {
queue!(
stdout,
style::PrintStyledContent(
" Ctrl+S: Salvar | Ctrl+F: Buscar | Ctrl+Q: Sair".dark_grey()
)
)
.unwrap();
}
}
/// Processa um evento de teclado
fn processar_tecla(&mut self, evento: KeyEvent) {
match (evento.modifiers, evento.code) {
// Atalhos com Ctrl
(KeyModifiers::CONTROL, KeyCode::Char('q')) => {
if self.modificado {
self.definir_mensagem(
"Arquivo modificado! Ctrl+Q novamente para sair sem salvar."
);
// Permite sair na segunda tentativa
self.modificado = false;
return;
}
self.encerrar = true;
}
(KeyModifiers::CONTROL, KeyCode::Char('s')) => {
if let Err(e) = self.salvar() {
self.definir_mensagem(&format!("Erro ao salvar: {}", e));
}
}
(KeyModifiers::CONTROL, KeyCode::Char('f')) => {
self.buscar();
}
// Movimentação
(_, KeyCode::Up) => self.mover_cima(),
(_, KeyCode::Down) => self.mover_baixo(),
(_, KeyCode::Left) => self.mover_esquerda(),
(_, KeyCode::Right) => self.mover_direita(),
(_, KeyCode::Home) => self.mover_inicio(),
(_, KeyCode::End) => self.mover_final(),
(_, KeyCode::PageUp) => self.pagina_cima(),
(_, KeyCode::PageDown) => self.pagina_baixo(),
// Edição
(_, KeyCode::Enter) => self.inserir_nova_linha(),
(_, KeyCode::Backspace) => self.apagar_atras(),
(_, KeyCode::Delete) => self.apagar_frente(),
(_, KeyCode::Tab) => {
// Insere 4 espaços no lugar de tab
for _ in 0..4 {
self.inserir_caractere(' ');
}
}
(_, KeyCode::Char(c)) => {
self.inserir_caractere(c);
}
_ => {} // Ignora teclas não mapeadas
}
}
}
fn main() {
let args: Vec<String> = env::args().collect();
// Cria o editor
let mut editor = Editor::novo();
// Abre o arquivo se fornecido como argumento
if args.len() >= 2 {
if let Err(e) = editor.abrir_arquivo(&args[1]) {
eprintln!("Erro ao abrir '{}': {}", args[1], e);
eprintln!("Criando novo arquivo.");
editor.nome_arquivo = Some(args[1].clone());
}
} else {
editor.definir_mensagem("Editor de Texto em Rust | Novo arquivo | Ctrl+Q para sair");
}
// Entra em modo raw e tela alternativa
terminal::enable_raw_mode().expect("Falha ao ativar modo raw");
let mut stdout = io::stdout();
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)
.expect("Falha ao configurar terminal");
// Loop principal do editor
loop {
editor.renderizar(&mut stdout);
// Espera por um evento de teclado
if event::poll(Duration::from_millis(100)).unwrap() {
match event::read().unwrap() {
Event::Key(evento) => editor.processar_tecla(evento),
Event::Resize(largura, altura) => {
editor.largura_tela = largura as usize;
editor.altura_tela = (altura as usize).saturating_sub(2);
}
_ => {}
}
}
if editor.encerrar {
break;
}
}
// Restaura o terminal ao estado original
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen)
.expect("Falha ao restaurar terminal");
terminal::disable_raw_mode().expect("Falha ao desativar modo raw");
println!("Editor encerrado.");
}
O loop principal segue o padrão clássico de aplicações interativas: renderizar, ler evento, processar, repetir. O Event::Resize captura redimensionamento do terminal e ajusta as dimensões dinamicamente. O Ctrl+Q implementa uma proteção contra perda de dados: se o arquivo foi modificado, a primeira tentativa mostra um aviso e somente a segunda efetivamente encerra. O Tab insere 4 espaços, evitando problemas de renderização com tabulações de largura variável.
Como Executar
Compile e execute o projeto:
# Compilar
cargo build --release
# Abrir um novo arquivo vazio
cargo run
# Abrir um arquivo existente
cargo run -- meu_arquivo.txt
# Ou usar o binário diretamente
./target/release/editor-texto codigo.rs
Controles do editor:
- Setas: Mover o cursor
- Home / End: Início / final da linha
- Page Up / Page Down: Rolar uma página
- Enter: Nova linha
- Backspace: Apagar caractere antes do cursor
- Delete: Apagar caractere na posição do cursor
- Tab: Insere 4 espaços
- Ctrl+S: Salvar arquivo
- Ctrl+F: Buscar texto
- Ctrl+Q: Sair (pede confirmação se houver alterações)
A barra de status na parte inferior mostra o nome do arquivo, indicador de modificação, número da linha e coluna atuais, e total de linhas. A barra de mensagens exibe feedback das operações e os atalhos disponíveis.
Desafios para Expandir
Syntax highlighting – Implemente destaque de sintaxe para linguagens como Rust, Python e Markdown, reconhecendo palavras-chave, strings, comentários e números com cores diferentes usando os estilos do crossterm.
Desfazer e refazer (Undo/Redo) – Implemente um sistema de histórico usando uma pilha de operações. Cada inserção, exclusão e junção de linhas empilha uma operação inversa. Ctrl+Z desfaz e Ctrl+Y refaz.
Seleção e clipboard – Adicione seleção de texto com Shift+setas (mantendo posição âncora e posição atual), recortar (Ctrl+X), copiar (Ctrl+C) e colar (Ctrl+V) usando um buffer interno.
Múltiplos buffers – Permita abrir vários arquivos simultaneamente e alternar entre eles com Ctrl+Tab, exibindo o nome de cada buffer na barra de status.
Busca e substituição – Estenda o Ctrl+F para incluir substituição com Ctrl+H, com opções de substituir a ocorrência atual, pular ou substituir todas de uma vez.
Veja Também
- Trabalhando com Strings – Manipulação de texto e caracteres Unicode
- Vec: Vetores Dinâmicos – Buffer de linhas e caracteres do editor
- Módulo fs (Sistema de Arquivos) – Leitura e escrita de arquivos
- stdin e stdout – Interação com o terminal
- Rust para Ferramentas CLI – Boas práticas para aplicações de terminal