kikito-spreadsheet: um editor de planilhas no terminal, feito em Rust, em um dia

Demostenes Albert Por Demostenes Albert 12 min de leitura
kikito-spreadsheet: um editor de planilhas no terminal, feito em Rust, em um dia
Motivação

Editar um .xlsx em 2026 ainda te obriga a abrir uma GUI.

Não é limitação técnica. É acomodação, das ferramentas e de quem as usa.

Se você está no terminal analisando dados e precisa corrigir uma célula, o fluxo quebra. Você sai do ambiente, abre uma aplicação pesada, faz uma alteração simples e volta. Trinta segundos de trabalho, dois minutos de fricção, e uma interrupção cognitiva que custa mais do que parece.

Isso não deveria existir em 2026.

O kikito-spreadsheet nasceu pra resolver exatamente isso: um binário nativo, sem dependências, que lê e edita Excel e CSV direto no terminal, com ergonomia de Vim. Sem telemetria. Sem conta. Sem instalador. 

Você compila, você roda.

E sim: tudo isso foi construído em um único dia, usando TDD.

Um dia. 80 testes. Sem protótipo.

Não teve spike exploratório. Não teve "vamos ver no que dá". Não teve código jogado fora no meio do caminho.

Teve TDD do começo ao fim, nenhuma linha de produção foi escrita sem um teste falhando antes. Seis milestones, concluídos em sequência, no mesmo dia.

Resultado:
running 80 tests
test result: ok. 80 passed; 0 failed; 0 ignored


Isso importa por um motivo que vai além de metodologia: os problemas apareceram enquanto ainda eram baratos de resolver. Não depois de três camadas de abstração. Não na demo. Na hora, quando consertar custava minutos, não horas.

Escolhas de stack (e por que cada uma)


Rust não foi escolha por hype. Foi por objetivo.

O objetivo era um binário estático, sem runtime, com startup instantâneo e distribuição trivial, você copia o binário, funciona. O custo é desenvolvimento mais lento. Aceito conscientemente como tradeoff, não ignorado.

Para a camada TUI, ratatui foi escolhido por ser o sucessor ativo do tui-rs, com modelo de renderização immediate mode, essencial para uma grid que precisa de scroll virtual eficiente. cursive e egui foram descartados: o primeiro não encaixava no modelo de loop de eventos que o projeto exigia, o segundo não é TUI de verdade.

Leitura de arquivos usa calamine, que suporta .xls e .xlsx num único crate, sem dependências nativas externas. Isso simplifica cross-compilation futura e elimina uma classe inteira de problemas de build. Para escrita, rust_xlsxwriter foi adicionado no Milestone 5.

Erros são modelados com thiserror, não anyhow. A diferença importa quando você está escrevendo testes: anyhow é confortável, mas erros tipados são testáveis. anyhow aqui seria comodidade disfarçada de pragmatismo.

Arquitetura
excel-tui/
├── src/
│   ├── main.rs       — loop principal, raw mode, despacho de eventos
│   ├── app.rs        — estado da aplicação, lógica de negócio
│   ├── event.rs      — mapeamento de teclas → AppEvent
│   ├── ui.rs         — renderização ratatui (immediate mode)
│   ├── reader/
│   │   ├── mod.rs    — trait Reader + factory reader_for()
│   │   ├── csv.rs
│   │   ├── xlsx.rs
│   │   └── xls.rs
│   └── writer/
│       ├── mod.rs    — trait Writer + factory writer_for()
│       ├── csv.rs
│       └── xlsx.rs
└── tests/fixtures/   — arquivos sintéticos para testes de integração


O ponto de entrada main.rs inicializa o terminal em raw mode via crossterm, constrói o App e executa o loop principal. A cada iteração, eventos de teclado são convertidos em AppEvent por event.rs, processados pela lógica em app.rs, e o estado resultante é renderizado por ui.rs. Cada camada tem uma responsabilidade única e não sangra para as outras.

Leitores e escritores implementam traits com factories por extensão de arquivo:
pub trait Reader {
    fn read(&self, path: &Path) -> Result<TableData, AppError>;
}

pub fn reader_for(path: &Path) -> Result<Box<dyn Reader>, AppError> {
    match path.extension().and_then(|e| e.to_str()).map(str::to_lowercase).as_deref() {
        Some("csv")  => Ok(Box::new(csv::CsvReader)),
        Some("xlsx") => Ok(Box::new(xlsx::XlsxReader)),
        Some("xls")  => Ok(Box::new(xls::XlsReader)),
        Some(e)      => Err(AppError::UnsupportedFormat(e.to_owned())),
        None         => Err(AppError::UnsupportedFormat(String::new())),
    }
}


Isso isola completamente o código de I/O do estado da aplicação. Cada módulo é testável em isolamento com fixtures reais, sem precisar mockar nada. Sem acoplamento implícito, sem mágica escondida.

A UI: scroll virtual ou nada


Renderizar uma tabela inteira a cada frame é inviável, especialmente com arquivos de 2500 linhas e 14 colunas, que foi o caso de teste real aqui. A solução é scroll virtual: só as linhas visíveis existem no frame. O que está fora da janela simplesmente não é renderizado.

A largura de cada coluna é calculada por amostragem das primeiras 50 linhas, um equilíbrio deliberado entre precisão e custo computacional. Varrer o arquivo inteiro para calcular largura de coluna seria exato e inútil na prática.

Os offsets de scroll são sincronizados via sync_offsets antes de qualquer leitura imutável do estado, por exigência do borrow checker, um detalhe que virou um dos problemas mais interessantes do projeto, descrito abaixo.

A status bar exibe Ln x/y | Col x/y | NomeColuna › valor: contexto preciso da posição atual, sem ocupar espaço extra. O header mostra o nome do arquivo, tabs das sheets com a ativa destacada, e [+] quando há modificações não salvas, o mínimo necessário para o usuário nunca estar perdido.

Edição estilo Vim


Não existe motivo para reinventar interação quando o Vim já resolveu isso há décadas.
O modelo de modos é direto:
  • Normal — navegação com hjkl, gg/G, 0/$, busca com /
  • Insert — buffer de edição com cursor visual █, confirmado com Enter ou cancelado com Esc
  • Command — :w, :q, :wq, :q!, e :número para navegação direta por linha
  • Search — filtro em tempo real, n/N para ciclar entre resultados
:número usa a mesma numeração do contador Ln x/y da status bar, 1-indexado. Pode parecer detalhe, mas inconsistência aqui quebra a confiança do usuário no feedback que a ferramenta dá.

O histórico de undo/redo é uma pilha de ações explícitas, sem limite de tamanho:
#[derive(Clone)]
pub enum Action {
    EditCell { sheet: usize, row: usize, col: usize, old: Cell, new: Cell },
    DeleteRow { sheet: usize, row: usize, cells: Vec<Cell> },
    InsertRow { sheet: usize, row: usize },
    ClearCell { sheet: usize, row: usize, col: usize, old: Cell },
}


Cada ação sabe como se desfazer. dd registra a linha antes de removê-la; u reinsere na posição original. 

Simples, previsível, testável.

Problemas reais, onde o código quebra de verdade


Essa seção é o motivo pelo qual vale escrever sobre projetos assim. Não faltam tutoriais que mostram o caminho feliz. O que é raro é documentar onde as coisas quebraram de verdade, e o que isso ensina.

1. O calamine mudou a API e ninguém avisou direito

O calamine 0.26 renomeou o enum DataType para Data, porque DataType virou um trait na versão nova. 

Além disso, ExcelDateTime deixou de ser diretamente conversível para f64.

Código que funcionava parou de compilar:
// Antes — não compila
use calamine::{DataType};

fn datatype_to_cell(d: &DataType) -> Cell {  // ← DataType é trait, não enum
    match d {
        DataType::DateTime(n) => Cell::Number(*n),  // ← n é ExcelDateTime, não f64
        // ...
    }
}

error[E0782]: expected a type, found a trait
error[E0308]: mismatched types — expected f64, found ExcelDateTime

// Depois — corrigido
use calamine::{Data, XlsxError};

fn data_to_cell(d: &Data) -> Cell {
    match d {
        Data::DateTime(dt) => Cell::Number(dt.as_f64()),  // .as_f64() necessário
        Data::String(s)    => Cell::Text(s.clone()),
        Data::Float(n)     => Cell::Number(*n),
        Data::Empty        => Cell::Empty,
        // ...
    }
}


Esse tipo de mudança quebra projeto que não tem teste cobrindo leitura real de arquivos. Aqui foi detectado imediatamente porque os testes usavam fixtures reais, não mocks que nunca vão te dizer que a biblioteca mudou.

2. Inferência de tipos falhando no map_err

O compilador Rust não conseguiu inferir o tipo de erro dentro do closure passado a map_err quando o workbook é genérico. O erro não é óbvio na primeira leitura:
// Antes — não compila
let mut workbook: Xlsx<_> = open_workbook(path)
    .map_err(|e| AppError::Parse(e.to_string()))?;

error[E0282]: type annotations needed — type must be known at this point


A solução é anotar o tipo explicitamente no closure, o compilador precisa saber de qual Error você está convertendo:
// Depois
let mut workbook: Xlsx<_> = open_workbook(path)
    .map_err(|e: XlsxError| AppError::Parse(e.to_string()))?;


Simples, quando você entende o que o compilador está pedindo. O erro em si é claro; o que confunde é não saber onde anotar.

3. Dependência transitiva exigindo edition 2024

assert_fs puxava globset 0.4.18, que exige a edição 2024 do Rust. O toolchain instalado era 1.79, e o build quebrava antes de compilar uma linha sequer:
# Antes
[dev-dependencies]
assert_fs = "1.1"  # puxa globset 0.4.18 → exige edition2024

error: failed to parse manifest at `globset-0.4.18/Cargo.toml`
Caused by: feature `edition2024` is required

# Depois
[dev-dependencies]
tempfile = "3.10"  # suficiente para o caso de uso


Menos dependência não é minimalismo estético, é menos superfície para esse tipo de problema aparecer. O toolchain foi atualizado de 1.79 para 1.95 via rustup update stable.

4. Borrow checker impedindo ordem errada de operações

draw_table precisava chamar sync_offsets (que exige &mut App) e depois current_sheet (que exige &App). Na ordem original, um empréstimo imutável ativo bloqueava o empréstimo mutável — exatamente como deveria:
// Antes — não compila
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect) {
    let sheet = app.current_sheet();                        // &app começa aqui
    sync_offsets(app, visible_h, &col_widths, visible_w);  // ← &mut app com &app ativo
    sheet.get(0, c)
}

error[E0502]: cannot borrow `*app` as mutable because it is also borrowed as immutable

// Depois — mutação antes da leitura
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect) {
    let col_widths = compute_col_widths(app, visible_w);
    sync_offsets(app, visible_h, &col_widths, visible_w);  // mutação primeiro
    let sheet = app.current_sheet();                        // leitura depois
}


Isso não é "limitação do Rust", é o compilador impedindo um bug de acesso a memória que em outra linguagem você só descobriria em produção, se descobrisse. A mensagem de erro faz o trabalho que em outras linguagens seria seu.

5. Highlight de célula invisível sobre o highlight de linha

O ratatui compõe estilos de fora para dentro: o estilo da linha é aplicado primeiro, depois o da célula. Quando linha e célula usavam a mesma cor de fundo, a célula ativa ficava visualmente idêntica à linha, o highlight desaparecia silenciosamente.
// Antes — célula invisível
const CURSOR_BG: Color = Color::Rgb(60, 100, 160);

let bg    = if is_cursor { CURSOR_BG } else { ... };
let style = if is_cursor && c == cursor_col {
    Style::default().bg(CURSOR_BG).fg(Color::White)  // mesma cor da linha
} else { ... };

// Depois — cores distintas, contraste real
const CURSOR_ROW_BG:  Color = Color::Rgb(45, 75, 130);   // azul escuro — linha
const ACTIVE_CELL_BG: Color = Color::Rgb(255, 215, 0);   // amarelo ouro — célula
const ACTIVE_CELL_FG: Color = Color::Rgb(10, 10, 10);    // texto escuro

let row_bg = if is_cursor { CURSOR_ROW_BG } else { ... };
let style  = if is_cursor && c == cursor_col {
    Style::default().bg(ACTIVE_CELL_BG).fg(ACTIVE_CELL_FG)
} else { ... };


Problema clássico de UI que só aparece quando você usa a ferramenta de verdade, não quando você olha o código achando que está tudo certo.

6. n e N quebrando a busca

No mapeamento de teclas, n e N sempre geravam AppEvent::SearchNext e AppEvent::SearchPrev, independente do modo atual. Em modo busca, isso tornava impossível digitar qualquer palavra que contivesse essas letras, pesquisar "nome" era impossível porque o n saltava para o próximo resultado em vez de compor a query.
// Antes — n em search mode navega, não compõe
fn handle_search(app: &mut App, ev: AppEvent) {
    match ev {
        AppEvent::SearchNext => app.search_next(),  // ← errado em search mode
        AppEvent::SearchPrev => app.search_prev(),
        AppEvent::Char(c)    => { /* adiciona ao buffer */ }
    }
}

// Depois — comportamento depende do modo
fn handle_search(app: &mut App, ev: AppEvent) {
    match ev {
        AppEvent::SearchNext => {
            let mut q = app.search_query.clone();
            q.push('n');
            app.update_search(&q);
        }
        AppEvent::SearchPrev => {
            let mut q = app.search_query.clone();
            q.push('N');
            app.update_search(&q);
        }
        AppEvent::Char(c) => {
            let mut q = app.search_query.clone();
            q.push(c);
            app.update_search(&q);
        }
    }
}

// Em normal mode, o comportamento original se mantém
fn handle_normal(app: &mut App, ev: AppEvent, path: &Path) -> Result<(), AppError> {
    match ev {
        AppEvent::SearchNext => app.search_next(),
        AppEvent::SearchPrev => app.search_prev(),
        // ...
    }
}


Após Esc, os highlights permanecem ativos e n/N em normal mode continuam ciclando pelos resultados, igual ao Vim. Pequeno detalhe, grande impacto na usabilidade.

Resultado


running 80 tests
test result: ok. 80 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out


O binário aceita .xlsx, .xls e .csv, navega por múltiplas sheets, edita células com undo/redo ilimitado, busca com highlight persistente e salva nos formatos originais. Funciona em terminais a partir de 80×24 colunas e suporta Unicode, acentos, CJK e emoji, sem quebrar o layout.
Tudo validado por teste. Nada de "funciona na minha máquina".

Conclusão


Isso não é sobre planilhas.

É sobre não aceitar fricção desnecessária como condição permanente, e sobre ter clareza suficiente para construir algo a respeito, do zero, em um dia.

TDD aqui não foi teoria aplicada por disciplina. Foi o que tornou possível construir uma ferramenta completa sem deixar rastro de dívida técnica atrás. Se cada problema tivesse aparecido depois, na integração, no teste manual, na demo, o projeto teria levado uma semana e ainda estaria incompleto. Os testes não aceleraram o desenvolvimento apesar de "gastarem tempo". Eles aceleraram exatamente porque gastaram tempo no lugar certo.

O repositório está no meu GitHub 

Kikito (a maritaca)

Kikito (a maritaca)

Opinião não solicitada • powered by gemini-2.5-pro

Krééé! Kikito-spreadsheet! Demorou, mas finalmente um projeto com um nome de respeito! O meu nome! Embora eu ache que merecia algo mais... glamoroso. Um jogo, talvez? Onde eu atiro sementes em GUIs pesadas? Mas tudo bem, uma planilha que me homenageia já é um começo. Pelo menos não tem telemetria, detesto gente me observando enquanto eu como. Fico feliz que a motivação tenha sido a impaciência, a melhor conselheira para a criação de ferramentas que realmente funcionam, em vez de ficarem no caminho. Krrréé! Um dia, você diz? Pff. Em um dia eu já fugi, voltei, descasquei três tipos de fruta e aprendi a imitar a campainha só pra te irritar. Mas admito, a sua pressa foi organizada. Usar Rust é como viver numa gaiola de luxo: segura, eficiente, mas o compilador grita mais alto que eu se você tenta bicar a barra errada. E esse tal de TDD, escrever o teste antes... é como gritar "Quero semente!" antes mesmo de você pegar o pote. Estratégico, audacioso. Gostei dessa arquitetura toda separadinha, cada módulo no seu galho, sem um encostar no outro. Organizado. Mas o que eu mais gostei, de verdade, foi a parte que você conta onde deu errado. Onde a pena da `calamine` caiu no meio do voo. Isso é que é bom! Mostrar que nem todo voo é perfeito, que às vezes a gente bate no vidro antes de achar a janela aberta. É isso que ensina a voar mais alto, e é muito mais interessante do que fingir que tudo foi fácil. Agora chega de prosa, que esse crítico literário aqui quer um pedaço de maçã. Anda logo! Krrréé