Projeto: Singularity
Linguagem: Rust (Edition 2021)
Plataforma alvo: Linux (Fedora/Wayland)
Status: Fase 3.5 / Pré-Fase 4 concluída — multi-sessão, gerenciamento de memória e hit-testing
Data: 2026-03-17
O Singularity é um emulador de terminal moderno inspirado na arquitetura de blocos do Warp. A premissa central é que cada comando executado pelo usuário gera um bloco independente — uma unidade visual que agrupa o comando e seu output, permitindo navegação, seleção e futuramente colapso/expansão de resultados.
A prioridade absoluta do projeto é performance bruta e baixo consumo de recursos: zero alocações desnecessárias no loop de renderização, latência de input sub-milissegundo e throughput de I/O do PTY sem bloqueio da UI thread.
A stack original sugerida incluía gpui como framework de UI. Foi descartado pelos seguintes motivos:
- API pública instável — sem garantias de compatibilidade entre versões
- Fortemente acoplado ao ecossistema interno do Zed (macOS-first)
- Sem suporte documentado para Wayland nativo no Linux
- Overhead de abstração desnecessário para um terminal
| Componente | Crate | Versão | Justificativa |
|---|---|---|---|
| Janela + eventos | winit |
0.30 | Suporte nativo Wayland via xdg-shell, API ApplicationHandler estável |
| Pipeline GPU | wgpu |
28 | Backend Vulkan/OpenGL, cross-platform, zero unsafe exposto |
| Renderização de texto | glyphon |
0.10 | Text rendering GPU-accelerated sobre wgpu via cosmic-text (shaping correto, suporte a Unicode) |
| PTY | portable-pty |
0.8 | Battle-tested, suporte a resize SIGWINCH, abstração cross-platform |
| Canais inter-thread | crossbeam-channel |
0.5 | Canais bounded lock-free, backpressure natural, zero overhead vs std::sync::mpsc |
| Parser VT/ANSI | vte |
0.13 | Parser de estado finito baseado no DEC ANSI parser, zero-copy, callbacks síncronos |
| Erros | anyhow |
1 | Ergonomia de error handling sem overhead de runtime |
| Async init | pollster |
0.4 | Block-on para inicialização síncrona do wgpu sem tokio runtime |
[profile.release]
lto = "fat" # Link-Time Optimization completo entre todos os crates
codegen-units = 1 # Compilação em unidade única — máxima otimização inter-procedural
opt-level = 3 # Otimização máxima
strip = true # Remove símbolos de debug do binário finalO isolamento de I/O é o requisito mais crítico: o event loop da UI nunca pode bloquear em operações de PTY.
┌─────────────────────────────────────────────────────────────┐
│ UI Thread │
│ winit event loop → handle_key → render() │
│ Lê: BlockStore (snapshot lock ~microsegundos) │
│ Escreve: input_tx (send não-bloqueante) │
└──────────────────────┬──────────────────────────────────────┘
│ crossbeam bounded(256)
▼
┌──────────────────────────────────────────────────────────────┐
│ Thread: pty-writer │
│ Drena input_rx → write_all() no stdin do PTY │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Thread: pty-reader │
│ read() bloqueante no master PTY (buf 64KB) │
│ → output_tx.send(chunk) │
└──────────────────────┬───────────────────────────────────────┘
│ crossbeam bounded(256)
▼
┌──────────────────────────────────────────────────────────────┐
│ Thread: vte-processor │
│ Drena output_rx → vte::Parser::advance() │
│ → VteHandler::print/execute/csi_dispatch │
│ → BlockStore::push_span / newline │
└──────────────────────────────────────────────────────────────┘
Canais bounded: a capacidade de 256 chunks por canal garante backpressure natural — se o renderer não conseguir acompanhar o output do PTY, a thread de leitura bloqueia no send() em vez de crescer a memória indefinidamente.
Responsável por:
- Abrir o par PTY via
portable_pty::native_pty_system().openpty() - Detectar o shell do usuário via
$SHELL(fallback:/bin/bash) - Spawnar o processo shell no slave PTY com
TERM=xterm-256coloreCOLORTERM=truecolor - Dropar o slave após o spawn (o master é suficiente para comunicação bidirecional)
- Spawnar as threads
pty-readerepty-writer
PtyHandle expõe:
input_tx: Sender<Vec<u8>>— canal para enviar bytes de teclado ao PTYtake_output_rx() -> Receiver<Vec<u8>>— consumível uma única vez pela thread VTEresize(cols, rows)— enviaSIGWINCHviaMasterPty::resize()
O output_rx é encapsulado em Option<Receiver> para garantir que seja consumido exatamente uma vez — tentativas subsequentes de take_output_rx() causam panic explícito, evitando bugs silenciosos de múltiplos consumidores.
Implementa vte::Perform no struct VteHandler, que mantém o estado SGR (Select Graphic Rendition) atual e alimenta o BlockStore diretamente via callbacks.
Estado SGR mantido:
cur_fg / cur_bg: TermColor— cor atual de foreground/backgroundcur_bold / cur_italic / cur_underline: bool— atributos de texto
TermColor suporta três modos:
pub enum TermColor {
Default, // cor padrão do tema
Indexed(u8), // paleta ANSI 0-255
Rgb(u8, u8, u8), // true-color (ESC[38;2;r;g;bm)
}A paleta de 256 cores é uma tabela estática const ANSI_256: [(u8,u8,u8); 256] — zero alocação em runtime.
Callbacks implementados:
| Callback | Ação |
|---|---|
print(char) |
Cria StyledSpan com cor/atributos atuais → BlockStore::push_span() |
execute(0x0A/0x0B/0x0C) |
BlockStore::newline() |
csi_dispatch('m', ...) |
apply_sgr() — atualiza estado de cor |
csi_dispatch('J', 2) |
Erase display → emite newline para separação visual |
Decisão de design: o VteHandler não mantém uma grade 2D de células. Em vez disso, alimenta o BlockStore diretamente com spans de texto. Isso elimina a necessidade de sincronizar snapshots da grade com os blocos (que era a causa raiz do bug de duplicação de output nas fases anteriores).
É o coração da arquitetura Warp-like. Gerencia a lista de blocos e é o único ponto de estado compartilhado entre a thread VTE e a UI thread.
Hierarquia de tipos:
BlockStore (Arc<Mutex<Inner>>)
└── Inner
├── finished: VecDeque<Block> — blocos commitados (despejo FIFO pelo front)
├── active: Block — bloco atual recebendo output
├── total_lines: usize — contador incremental de linhas (sem scan O(N))
└── version: u64 — contador de mudanças
Block
├── command: String — texto do comando que originou o bloco
├── lines: Vec<OutputLine> — linhas finalizadas (com \n)
├── current_line: Vec<StyledSpan> — linha parcial em construção
└── finished: bool
OutputLine(Vec<StyledSpan>)
StyledSpan
├── text: String
├── r, g, b: u8
├── bold: bool
└── italic: bool
Fluxo de dados:
VteHandler::print(c)→BlockStore::push_span()— appenda span àcurrent_linedo bloco ativoVteHandler::execute('\n')→BlockStore::newline()— movecurrent_lineparalinesviastd::mem::take()AppState::handle_key(Enter)→BlockStore::commit(cmd)— finaliza linha parcial, move bloco ativo parafinished, cria novo bloco ativo com o próximo comando
Sistema de versão: cada mutação do BlockStore incrementa Inner::version: u64. O renderer compara blocks.version() com cache.version antes de reconstruir os buffers de texto — se a versão não mudou, o frame é renderizado com os buffers cacheados sem nenhuma alocação.
trimmed_lines(): remove trailing blank lines do output antes da renderização, evitando espaço vazio desnecessário no final de cada bloco.
Encapsula todo o estado de uma aba: PtyHandle, BlockStore, TerminalState, BufferCache e input_line. Extraído do AppState para permitir múltiplas instâncias independentes.
pub struct Session {
pub pty: PtyHandle,
pub blocks: BlockStore,
pub terminal: TerminalState,
pub input_line: String,
pub cache: BufferCache,
pub title: String,
}Session::new() é o único ponto de criação — spawna o PTY, inicializa o parser VTE, spawna a thread vte-processor e inicializa o BufferCache. Criar uma nova aba é uma única chamada.
O BufferCache agora inclui também o LayoutIndex (ver seção 7).
Segue o padrão canônico do glyphon:
Instance → Surface → Adapter (HighPerformance) → Device + Queue
→ SurfaceConfiguration (sRGB, Fifo/vsync)
→ FontSystem + SwashCache + Cache + Viewport + TextAtlas + TextRenderer
O pollster::block_on() é usado para inicialização síncrona dentro do ApplicationHandler::resumed() — evita a necessidade de um tokio runtime apenas para setup.
struct BufferCache {
version: u64,
block_buffers: Vec<GlyphonBuffer>,
input_buffer: GlyphonBuffer,
block_tops: Vec<f32>,
block_heights: Vec<f32>,
}O cache é invalidado (e reconstruído via rebuild_buffers()) apenas quando blocks.version() != cache.version. Isso garante que frames sem mudança de conteúdo não fazem nenhuma alocação de heap — apenas montam TextArea com referências aos buffers existentes e chamam text_renderer.prepare().
Para cada bloco visível:
- Calcula
content_h = n_lines * FONT_H + BLOCK_PAD_Y * 2.0 - Constrói
Vec<(String, Attrs)>com spans coloridos - Cria
GlyphonBuffer, chamaset_rich_text()com os spans - Chama
shape_until_scroll()para shaping via cosmic-text - Armazena buffer + posição Y no cache
A linha parcial (current_partial()) é incluída no último bloco para exibir output em tempo real antes do \n.
render()
├── Verifica versão → rebuild_buffers() se necessário
├── Monta Vec<TextArea> com referências aos buffers cacheados
│ ├── Blocos: clipping por scroll_h (h - INPUT_BOX_H)
│ └── Input box: sempre visível, posição fixa em h - INPUT_BOX_H
├── viewport.update()
├── text_renderer.prepare() — rasteriza glifos no atlas GPU
├── begin_render_pass (LoadOp::Clear BG_MAIN)
├── text_renderer.render()
└── queue.submit() + frame.present() + atlas.trim()
O atlas.trim() ao final de cada frame libera glifos não utilizados do atlas de textura GPU, evitando crescimento ilimitado de VRAM.
O input é gerenciado inteiramente na UI thread — input_line: String acumula os caracteres digitados. Ao pressionar Enter:
blocks.commit(cmd)— congela o bloco ativopty.input_tx.send(format!("{}\r", cmd))— envia o comando ao PTYinput_line.clear()— limpa o campo
O \r (carriage return) é enviado em vez de \n porque o PTY opera em modo raw e espera CR para processar o comando.
A alternativa óbvia seria Arc<Mutex<Vec<Session>>> para compartilhar o estado entre threads. Foi descartada: a UI thread é a única consumidora desse Vec, então o Arc<Mutex> adicionaria overhead de lock sem nenhum benefício real.
O SessionManager vive inteiramente na UI thread — um Vec<Session> simples com um índice active: usize. Sem lock, sem overhead, sem risco de deadlock entre UI e PTY.
UI Thread
└── SessionManager { sessions: Vec<Session>, active: usize }
├── Session[0] ←→ pty-reader[0] / pty-writer[0] / vte-processor[0]
├── Session[1] ←→ pty-reader[1] / pty-writer[1] / vte-processor[1]
└── Session[N] ←→ pty-reader[N] / pty-writer[N] / vte-processor[N]
O isolamento real já existia na camada abaixo: cada Session spawna suas próprias threads pty-reader, pty-writer e vte-processor, que se comunicam com a UI exclusivamente via crossbeam_channel bounded e Arc<Mutex<Inner>> do BlockStore. Sessões em background continuam processando PTY output normalmente — um cargo build rodando em outra aba não trava.
struct SessionManager {
sessions: Vec<Session>,
active: usize,
}Invariante mantida em todos os métodos: sessions nunca está vazio e active é sempre um índice válido.
| Método | Comportamento |
|---|---|
current() / current_mut() |
Acesso à sessão ativa |
add(session) |
Appenda nova sessão |
next() / prev() |
Alterna com wrap-around |
count() |
Número de sessões abertas |
Cada Session carrega seu próprio BufferCache. Quando o usuário alterna para uma aba que recebeu output em background, o cache dessa aba tem version != blocks.version() — o rebuild_buffers() é chamado uma única vez no próximo frame. Se a aba não recebeu output enquanto estava em background, o cache está válido e a alternância é O(1): apenas troca o índice active.
| Atalho | Ação |
|---|---|
Ctrl+T |
Cria nova sessão PTY e ativa imediatamente |
Ctrl+Tab |
Próxima aba (wrap-around) |
Ctrl+W |
Fecha aba ativa (mínimo de 1 mantida) |
Ctrl+1..9 |
Pula diretamente para aba N |
Ao fechar uma aba via Ctrl+W, as threads de PTY associadas encerram naturalmente: o Sender do canal input_tx é dropado junto com o PtyHandle, causando RecvError na thread pty-writer. O pty-reader encerra ao receber EOF do master PTY quando o processo shell termina.
AppState::resize() itera sobre todas as sessões para enviar SIGWINCH — processos como vim ou htop rodando em abas inativas se adaptam ao novo tamanho sem precisar estar em foco.
O Inner do BlockStore usava Vec<Block> sem teto de capacidade. Em sessões com output contínuo (tail -f, cargo build de projetos grandes), esse vetor cresce linearmente sem limite — tanto em heap quanto no BufferCache de glifos na VRAM.
Solução: VecDeque + contador incremental de linhas
struct Inner {
finished: VecDeque<Block>, // era Vec<Block>
active: Block,
total_lines: usize, // mantido incrementalmente — zero scan O(N)
version: u64,
}O limite é definido por número de linhas totais (não por número de blocos):
pub const MAX_LINES: usize = 10_000;Linhas são a unidade correta porque um único bloco pode ter 1 linha (echo ok) ou 5.000 linhas (cargo test). O despejo ocorre em commit() via pop_front() O(1) amortizado — remove o bloco mais antigo inteiro, preservando integridade semântica (não quebra um bloco no meio).
| Cenário | Antes | Depois |
|---|---|---|
| Sessão idle | ~KB | ~KB |
tail -f por 1h |
cresce indefinidamente | platô em ~2MB |
cargo build grande |
pico de dezenas de MB | platô em ~2MB |
| 10 abas com output contínuo | N × ∞ | N × ~2MB |
Por que não ring buffer manual? Blocos têm tamanho variável (1 a milhares de linhas) — um ring buffer de capacidade fixa exige elementos de tamanho uniforme. VecDeque resolve com pop_front() O(1) sem gerenciar índices head/tail manualmente.
O glyphon não expõe API de hit-testing reverso. Sem mapeamento entre pixels e estado lógico, é impossível implementar seleção de texto, hover ou colapso de blocos.
Solução: LayoutIndex com busca binária
pub struct BlockRect { pub y0: f32, pub y1: f32, pub x0: f32 }
pub struct LayoutIndex {
pub rects: Vec<BlockRect>, // um por bloco, ordenado por y0 crescente
}O LayoutIndex vive dentro do BufferCache e é reconstruído junto com os GlyphonBuffer — sempre sincronizado, zero estado desincronizado possível.
API:
pub fn hit_test(&self, x: f32, y: f32, font_w: f32) -> Option<HitResult>
// Retorna: HitResult { block_idx, line, col }Algoritmo:
1. partition_point(|r| r.y0 <= y) → O(log k), k = blocos visíveis
2. Verifica y ∈ [y0, y1) → O(1)
3. line = (y - y0 - BLOCK_PAD_Y) / FONT_H → O(1)
4. col = (x - x0) / font_w → O(1)
partition_point é a busca binária da stdlib sobre um Vec contíguo — excelente localidade de cache, O(log 50) ≈ 6 comparações para k típico. Uma interval tree adicionaria complexidade sem ganho mensurável para k < 50.
O CursorMoved do winit chama o hit-test passivamente e loga o resultado em debug — infraestrutura pronta para hover, seleção e colapso na Fase 4.
Causa: A abordagem inicial sincronizava o bloco ativo com snapshots da grade VTE (update_from_grid). Quando um bloco era commitado, a grade continuava sendo atualizada pela thread VTE e o conteúdo aparecia tanto no bloco finalizado quanto no novo bloco ativo.
Solução: Eliminação completa da grade 2D. O VteHandler agora alimenta o BlockStore diretamente via callbacks push_span/newline. Cada bloco acumula seu próprio output de forma independente — não há estado compartilhado entre blocos.
Causa: A implementação anterior criava um GlyphonBuffer novo por bloco a cada frame, mesmo sem mudança de conteúdo. O shaping de texto via cosmic-text é custoso — com múltiplos blocos, o tempo de frame excedia o timeout de aquisição do swapchain.
Solução: BufferCache com invalidação por versão. Buffers são reconstruídos apenas quando BlockStore::version muda. Frames sem mudança de conteúdo têm custo O(n_blocos_visíveis) apenas para montar as TextArea — sem alocações de heap.
request_adapter()retornaResultem wgpu 28 (nãoOption) — uso de.map_err()em vez de.ok_or_else()RenderPassColorAttachmenttem campodepth_slice: Noneadicionado em wgpu 28RenderPassDescriptortem campomultiview_mask: Noneadicionado em wgpu 28Buffer::set_text()eset_rich_text()recebem&Attrs(referência) em cosmic-text 0.15set_rich_text()aceitaIntoIterator<Item = (&str, Attrs)>— não(text, AttrsList)DeviceDescriptor::request_device()não tem segundo argumentoNoneem wgpu 28
singularity-core/
├── Cargo.toml
└── src/
├── main.rs — event loop winit, SessionManager, renderer wgpu/glyphon, CursorMoved hit-test
├── session.rs — Session, BufferCache, LayoutIndex, BlockRect, HitResult, rebuild_buffers
├── pty.rs — PTY setup, threads reader/writer, PtyHandle
├── terminal_state.rs — parser VTE, VteHandler, TermColor, paleta ANSI 256
└── block_store.rs — BlockStore, VecDeque, MAX_LINES, evict_if_needed
- Backgrounds por bloco (
#2C2C2E) via wgpu render pass separado, usandoBlockRect.y0/y1doLayoutIndexdiretamente - Hover highlight —
HitResult.block_idxidentifica o bloco sob o cursor para mudar cor de fundo - Bordas e separadores visuais entre blocos
- Barra de abas (tab bar) renderizada no topo
- Scroll do histórico de blocos (mouse wheel + teclado)
- Seleção de texto —
LayoutIndexidentifica o bloco,Buffer::hitdo glyphon resolve o glifo exato - Colapso/expansão de blocos —
collapsed: boolpor bloco,LayoutIndexreflete altura reduzida - Ctrl+C para SIGINT no processo filho
- Syntax highlighting do comando no input box (via
tree-sitter) - Autocompletion integrado
- Busca no histórico de blocos
# Dev (sem otimizações, com debug info)
cargo run
# Release (LTO + opt-level 3)
cargo build --release
./target/release/singularity-core
# Variável de ambiente para logs detalhados
RUST_LOG=info cargo runDependências de sistema (Fedora):
sudo dnf install vulkan-loader-devel libxkbcommon-devel wayland-devel