Skip to content

CodeEditor pt BR

Edgar Mesquita edited this page Aug 13, 2026 · 3 revisions

Editor de código

🌐 Esta página em: English · Português

O SDK publica o modelo de um editor de código, não só uma caixa com realce de sintaxe: um documento feito de linhas, um realçador incremental, um histórico de desfazer que pensa em palavras, e um controller cujos métodos são os comandos que uma IDE põe nos menus dela. Tudo nesta página vive em eQuantic.UI.Primitives, lógica pura com zero dependências e sem pixels, que é o que permite a um app construído sobre isso testar unitariamente os próprios comandos de edição sem tela, e o que permite ao mesmo editor rodar como pixels de GPU no nativo e como DOM no web.

Por que uma camada de modelo? Porque "um editor" é 90% aritmética: em qual coluna um cursor cai depois de ↓ por uma linha curta, o que o Backspace faz dentro da indentação, qual chave casa com qual. Costure isso num widget e só dá para testar clicando. Mantenha aqui e testa-se afirmando.


Uma grade, e um cursor que dá para ver

O cursor e a faixa de seleção são posicionados por aritmética (contentTop + linha × lineHeight, contentLeft + coluna × columnWidth), o que só funciona enquanto TODA parte do editor concorda com esses números. Três regras os mantêm concordando, cada uma delas um bug que já foi publicado:

  • Uma medição. O CodeEditor mede a grade e a entrega ao CodeBlock (Metrics), que nunca mede a dele. Duas medições divergem no momento em que as duas metades são construídas com contextos diferentes, e aí um cursor fica entre as linhas.
  • A tinta das marcas anda no nó (CaretColor / SelectionColor), não no realizador. Um editor sobre uma laje inversa escreve com uma tinta própria; pintado a partir do tema da página, um cursor fica invisível exatamente na superfície em que as pessoas digitam. Só o piscar e a porteira de foco são mecânica de folha de estilo: 500ms por fase, o mesmo que o host nativo pisca pelo relógio dele.
  • Meça com uma fonte que o CSS consiga parsear. O FontWeight rebaixa para um nome de membro; um canvas que recebe regular 11.5px … mantém 10px sans-serif e responde com um avanço proporcional, em silêncio.

As peças

Tipo O que é
CodeDocument O texto, guardado como linhas. Imutável: cada edição devolve um documento novo (é isso que o desfazer guarda).
CodePosition / CodeRange Uma linha+coluna, e um par direcionado (âncora → foco) para que shift+seta saiba qual ponta está arrastando.
ICodeLanguage Um tokenizador de uma linha por vez com estado carregado adiante, mais as Rules da linguagem.
CodeHighlighter As cores de um documento, mantidas atualizadas incrementalmente.
CodeHistory Desfazer/refazer que junta uma sequência de digitação num passo só.
CodeEditorController Todo comando de edição, sobre um documento e uma seleção: o editor menos os pixels.

Documentos são linhas

var document = CodeDocument.FromText(File.ReadAllText(path));   // CRLF, CR e LF, todos aceitos
document.LineCount;                    // 42
document.Line(7);                      // "    public void Run()"
document.OffsetOf(new CodePosition(7, 4));   // ↔ PositionOf(offset)

Linhas em vez de uma string só porque tudo que um editor faz tem formato de linha: a calha as numera, o tokenizador as colore uma por vez, o cursor se move entre elas, e um toque de tecla não pode recopiar um megabyte.

Uma primitiva de edição, substituir um intervalo por texto, cobre inserir (intervalo vazio), apagar (texto vazio) e digitar sobre uma seleção (os dois):

var next = document.Replace(range, "renamed", out var caret);

O Clamp prende qualquer posição dentro do documento, que é por que nenhuma navegação precisa pensar nas bordas. O LineStart implementa o Home que todo editor tem: o primeiro caractere não branco, e só a coluna zero quando o cursor já está lá.


Linguagens

Incluídas: C#, TypeScript/JavaScript, Python, JSON, XML (e .csproj/.plist junto), e texto puro como o fallback que sempre renderiza.

var language = CodeLanguages.For("cs");          // por nome ou extensão; PlainText quando desconhecida
CodeLanguages.Register("sql", new SqlLanguage()); // um app traz o dialeto dele

Um tokenizador lê uma linha e devolve o estado em que a próxima linha começa:

int Tokenize(string line, int state, List<CodeToken> into);

Esse formato é o que torna barato recolorir um toque de tecla, e é o único jeito de uma construção que atravessa linhas funcionar: um comentário de bloco, uma string verbatim de C#, um template literal de JS, uma docstring de Python. Os tipos de token são um conjunto pequeno e fechado (Keyword, Type, String, Number, Comment, Operator, Punctuation, Function, Attribute, Property, Constant, Plain), porque um design system tem uma paleta para código.

Cada linguagem também declara as regras dela, e todo comportamento é construído a partir delas:

public CodeLanguageRules Rules { get; } = new()
{
    LineComment = "#",                       // ⌘/ ; null = o comando não faz nada (JSON)
    IndentAfter = [':', '(', '[', '{'],      // o que abre um nível (Python indenta depois de dois pontos)
    OutdentOn  = [')', ']', '}'],
    IndentWidth = 4,
    InsertSpaces = true,
};

Realce incremental

var highlighter = new CodeHighlighter(CodeLanguages.CSharp);
var tokens = highlighter.TokensFor(document, line);

// depois de uma edição
int repaintThrough = highlighter.LineChanged(document, line);

O LineChanged re-tokeniza aquela linha e continua só enquanto o estado final continuar saindo diferente, o que acontece quando um comentário de bloco ou uma string de várias linhas abre ou fecha, e em nenhum outro caso. Ele devolve até onde as cores se moveram, para que quem chamou repinte só isso.


O controller

O CodeEditorController é o comportamento do editor. Uma IDE o dirige a partir do mapa de teclas dela, do menu dela ou do language server dela; o widget é só o que o desenha.

var editor = new CodeEditorController(text, CodeLanguages.CSharp);

editor.Type('(');                 // fecha sozinho, o cursor cai dentro
editor.InsertNewLine();           // herda a indentação, abre um bloco, solta a chave de fechamento
editor.Indent();                  // cursor → próxima parada de tabulação; seleção → todas as linhas
editor.ToggleLineComment();       // ⌘/ adiciona, ou remove quando todas as linhas já são comentário
editor.Move(CodeMotion.Line, CodeDirection.Forward, extend: true);
editor.Undo();  editor.Redo();
editor.FindNext("needle");
editor.MatchingBracket(editor.Caret);
editor.Apply(range, "renamed");   // um refactor: desfaz como qualquer coisa digitada

Os comportamentos que você ganha de graça

  • Pares: um colchete de abertura se fecha sozinho; digitar a metade de fechamento sobre o gêmeo autoinserido passa por cima dele em vez de duplicá-lo; apagar a metade de abertura leva o fechamento junto; uma aspa dentro de uma palavra continua sendo um apóstrofo (don't).
  • Indentação: uma linha nova herda a indentação atual e ganha um nível depois de {; Enter entre {} abre o bloco e solta o fechamento na linha dele; Backspace no espaço em branco inicial remove um passo inteiro; Tab vai para a próxima parada, não um número fixo de espaços.
  • Movimento: uma sequência de ↓ por linhas irregulares lembra a coluna de onde começou; os passos por palavra param onde um leitor pararia; um → simples colapsa a seleção na borda dela.
  • Desfazer: uma sequência de digitação é um passo; mover o cursor termina a sequência; uma edição nova mata o ramo de refazer.

Eventos

editor.Changed += edit => { /* flag de sujo, language server, diff */ };
editor.SelectionChanged += range => { /* barra de status: Ln 12, Col 4 */ };

O Changed carrega o CodeEdit: intervalo, texto removido, texto inserido, seleção de cada lado. Uma IDE assina edições, não toques de tecla, porque uma colagem e um refactor são edições que ninguém digitou.


Pontos de extensão para uma IDE

Estes são contratos que o app implementa; o trabalho do editor é posicionar o que eles devolvem.

public interface ICodeCompletionProvider
{
    IReadOnlyList<char> TriggerCharacters => ['.'];
    Task<IReadOnlyList<CodeCompletionItem>> CompleteAsync(
        CodeDocument document, CodePosition position, CancellationToken cancellation);
}

public interface ICodeHoverProvider   { Task<CodeHover?> HoverAsync(); }
public interface ICodeFoldProvider    { IReadOnlyList<CodeFold> FoldsFor(CodeDocument document); }

Assíncronos porque a resposta normalmente cruza uma fronteira de processo, e um editor que bloqueia nela é um editor que engasga. O IndentationFoldProvider é o provedor de dobras padrão: ele funciona para toda linguagem, incluindo aquelas para as quais ninguém escreveu um parser.

Dados que um app entrega por frame:

Tipo Para
CodeDiagnostic O rabisco sob o código e a linha na lista de problemas. Um record, Range + Severity + Message (+ Code, Source).
CodeDecoration Qualquer marca extra sobre um intervalo: resultados de busca, o símbolo sob o cursor, uma chave casada, um trecho de diff. Highlight/Squiggle/Outline/Strike.
CodeGutterMarker Breakpoints, status do git, a instrução em que um depurador parou.

CodeBlock: a superfície somente leitura

O modelo desenha por um componente. Cada linha vira uma Row de trechos Text coloridos, que é por que ele não precisa de suporte de motor além da face monoespaçada: a mesma árvore renderiza como pixels de GPU e como DOM.

new CodeBlock(source, "csharp")
{
    ShowLineNumbers = true,
    FirstLineNumber = 120,          // um fragmento citado da linha 120 diz 120
    MaxHeight = 320,                // limita a altura e rola além disso
    ActiveLine = 4,                 // a linha atual do depurador
    GutterMarkers = [new CodeGutterMarker(4, CodeGutterKind.Breakpoint)],
    Decorations  = [new CodeDecoration(range, CodeDecorationKind.Search)],
    OnGutterPressed = line => ToggleBreakpoint(line),
    OnCopy = () => clipboard.Write(source),
    Caption = "Program.cs",
}
Propriedade Para que serve
Inverse Uma laje escura nos DOIS modos: código como figura numa documentação, não como controle.
Highlighter Reuse um entre frames para que a coloração continue incremental (um editor reusa; um trecho isolado não precisa).
Size O tamanho do próprio código; a calha o segue.
Standalone Se o bloco é o widget inteiro (laje própria, viewport próprio) ou conteúdo cru que algo de fora enquadra e rola. Verdadeiro por padrão; o CodeEditor o põe como falso.
ViewportWidth Quão largo o viewport acabou sendo, devolvido pelo layout. O conteúdo nunca é mais estreito que isso e nunca mais largo do que precisa.

Duas regras que o componente mantém e que é fácil errar:

  • A calha é MEDIDA, não adivinhada: context.MeasureText(lastNumber + "0", style). Um arquivo com 1000 linhas precisa de uma coluna que um de 10 não precisa.
  • Linhas longas rolam para o lado, nunca quebram. Uma linha de código quebrada perdeu a única coisa que a indentação dela estava te dizendo.

Medir faz parte do contexto

O ComponentContext.MeasureText(text, style) e o MonoAdvance(style) respondem quão larga uma string SERIA, em dp, antes de ser posicionada. O nativo pergunta ao serviço de texto da plataforma; o web pergunta ao browser por um contexto 2D de canvas usando as mesmas pilhas de fonte que o CSS usa, então os dois respondem com os mesmos números com que cada alvo vai posicionar o texto, que é do que depende mapear um clique para uma coluna.

CodeEditor: a superfície editável

O mesmo desenho, mais as três coisas que fazem dele um editor: um cursor, uma seleção e um teclado.

new CodeEditor(source, "csharp")
{
    OnChanged = text => _dirty = true,
    OnSelectionChanged = range => _status = $"Ln {range.Focus.Line + 1}, Col {range.Focus.Column + 1}",
    Autofocus = true,
    ReadOnly = false,
}

O componente é dono de um CodeEditorController e o entrega a um nó CodeSurface. Uma IDE recorre ao editor.Editor para rodar comandos que ninguém digitou (um formatador, uma renomeação, a edição de um language server), e eles desfazem como qualquer outra coisa, porque passam pela mesma primitiva.

Um keymap, duas superfícies

O CodeKeymap.Handle(editor, key, modifiers, clipboard) é onde um NOME de tecla vira um comando. Ele é C# puro, então transpila junto com todo o resto e as duas superfícies chamam a mesma função: o host do macOS a partir do keyDown dele, o browser a partir do keydown dele. Nada sobre o que ⌥← ou ⇧Tab significam é decidido num realizador.

Tecla O que faz
←→↑↓ caractere / linha; anda por palavra, vai à borda da linha
⌘↑ ⌘↓ ⌘Home ⌘End o documento inteiro
+ qualquer uma delas estende a partir da âncora
Enter linha nova, herdando a indentação (um nível a mais depois de {)
Tab / Tab indenta / desindenta: a seleção, ou até a próxima parada de tabulação
Backspace / Delete um caractere; leva a palavra; espaço em branco inicial vai um passo inteiro
⌘Z / ⇧⌘Z desfazer / refazer, juntando uma sequência de digitação numa coisa só
⌘A ⌘C ⌘X ⌘V selecionar tudo, copiar, recortar, colar; copiar sem seleção leva a linha
⌘/ alternar comentário de linha (nada numa linguagem que não tem)
Escape SAI do editor; um que prende o Escape é um do qual você não consegue sair

Caracteres digitados não passam pelo keymap: o que um toque de tecla produz é assunto da plataforma (uma tecla morta, um método de entrada, "á" a partir de três eventos), então o texto chega como string e vai para o Type, onde vivem os pares que se fecham sozinhos e a regra de passar por cima do fechamento.

A geometria é aritmética

A face é monoespaçada, então uma (linha, coluna) É (contentTop + linha × lineHeight, contentLeft + coluna × columnWidth), e um cursor repinta a cada toque de tecla sem medir nada nem refazer o layout. Os dois realizadores usam os mesmos números, e o CodeBlock.MetricsFor é o único lugar de onde eles vêm; dois cálculos independentes divergiriam por um pixel e depois por um caractere.

Uma seleção é uma FAIXA POR LINHA, nunca um retângulo sobre o intervalo: um retângulo único cobriria a indentação de linhas que o intervalo nunca tocou.

Um espaço de coordenadas

Desde 0.2.0-preview.21

As marcas são desenhadas contra a SUPERFÍCIE que as segura, então nada pode rolar dentro dela. O viewport vive FORA do CodeSurface (o editor o constrói), e a superfície viaja com o código:

Box (a laje, cortada)
 └ ScrollView (vertical, quando o MaxHeight a limita)
    └ ScrollView (horizontal)
       └ CodeSurface          ← move com o código, então as marcas também
          └ CodeBlock         ← Standalone = false: conteúdo cru, sem laje, sem viewport

Errar isso não é sutil quando você procura, e é invisível até procurar: um bloco que rola DENTRO da superfície põe o código num espaço e o cursor em outro. Role uma linha longa para o lado e o texto viaja enquanto o cursor fica para trás; clique, e a coluna é lida como se nada tivesse rolado. Pôr o viewport do lado de fora torna cada uma dessas somas verdadeira por construção: não sobra nada para manter em sincronia.

Duas consequências que vale declarar, porque cada uma foi um bug:

  • O conteúdo tem a largura do VIEWPORT, nunca menos que a linha mais longa. Só preencher é por que a rolagem lateral nunca rolava: uma view de rolagem cujo conteúdo tem exatamente o tamanho dela não tem o que mover. Dimensionar só pelo código é o erro oposto: um clique no espaço vazio à direita de uma linha curta cairia em nada.
  • A largura volta DO layout (ViewportWidth), do jeito que a altura já voltava. Os dois alvos discordam sobre o que preencher significa dentro de uma view de rolagem lateral (uma página resolve 100% contra o rolador, o Photon mede o conteúdo sem limite no eixo da rolagem), e um número reportado é a aritmética com que os dois realizadores concordam.

O cursor volta para a vista

Desde 0.2.0-preview.22

Andar com a seta para fora da borda de uma linha longa, ou para baixo além da última visível, deixava o cursor onde a aritmética o punha: fora da caixa. Duas coisas têm que estar certas, e cada uma é fácil de errar de um jeito que parece implementado:

  • Qual elemento. Cada toque de tecla reconstrói a árvore, então a superfície em que o handler rodou já está desanexada quando qualquer coisa roda depois, e o scrollIntoView num cursor desanexado tem sucesso em silêncio. A superfície carrega o caminho dela (data-eq-code), e a revelação resolve por ele.
  • Quando. O render é despejado num frame de animação, então um microtask acha um cursor que ainda não se moveu e corretamente decide que ele já está na tela. Ele espera o frame depois do despejo, e TAMBÉM um timeout, o mesmo par que o agendador de render mantém, porque uma aba escondida ou estrangulada para de entregar frames e o render acontece de qualquer jeito.

A calha fica onde está

Desde 0.2.0-preview.25

Os números são uma coluna própria, AO LADO da rolagem lateral e dentro da vertical: eles descem o arquivo com o código e ficam parados enquanto ele desliza de lado.

Dois outros arranjos foram tentados antes e os dois estavam errados do mesmo jeito. Dentro da rolagem, os números iam embora com o código e o leitor perdia o número da linha que estava lendo. Sobrepostos por cima dela, o código deslizava POR BAIXO de uma coluna opaca e caracteres reais sumiam — using virava eQuantic.UI.Core;, o que parece um bug de renderização e na verdade é de camada.

Ao lado, os dois continuam verdadeiros e nenhum compensa o outro. O preço está declarado nas métricas:

ContentLeft  =  o padding esquerdo do próprio código      ← onde a coluna 0 começa
             ≠  calha + padding                            ← o que era antes

Essa linha é por que isto exigiu uma mudança deliberada e não um retoque. A coluna zero é de onde o cursor, a faixa de seleção e toda decoração começam a contar, então mover a origem dela move as três de uma vez — que é exatamente por que ela é uma propriedade e não três, e por que todas puderam ser movidas numa edição. O ESPAÇO entre os números e o código pertence à calha agora, não ao código: um padding dentro da rolagem desliza embora, e os dígitos acabavam encostando no primeiro caractere.

Buscar, casar, e arquivos longos demais para construir

Buscar

O ⌘F abre uma barra sobre o canto superior direito, sobre e não acima: código que salta quando você abre a busca perdeu a linha que você estava olhando. Todo resultado é lavado e o ATUAL é contornado, porque um "próximo resultado" que move algo invisível não te disse nada. Enter e as setas percorrem; a contagem lê 3/17.

Uma IDE com a própria interface de busca pula tudo isso e define Search / SearchMatchCase diretamente.

Chaves e colchetes

O MatchBrackets (ligado por padrão) contorna o delimitador contra o qual o cursor está e o par dele. Um cursor fica ENTRE caracteres, então ele pertence ao delimitador de qualquer um dos lados, e o de TRÁS vence: tendo acabado de digitar ), é esse que você quer dizer.

As duas marcas são CodeDecorationKind.Outline, não uma lavagem: uma lavagem esconderia o caractere para o qual a marca está apontando.

Decorações são intervalos

Uma decoração é um INTERVALO, e ela desenha como um retângulo por linha que atravessa, a mesma aritmética que a faixa de seleção usa.

Tipo O que desenha
Highlight uma lavagem de fundo: um resultado de busca, um símbolo sob o cursor
Outline uma caixa em volta do intervalo: um delimitador casado
Squiggle um filete abaixo: um diagnóstico
Strike um filete atravessando: apagado num diff, código inalcançável

Numa laje Inverse cada uma delas pega a metade ESCURA da cor dela, pela mesma razão que os tokens pegam: um token de modo claro sobre código escuro lê como falha de renderização.

Virtualização

Um CodeEditor com um MaxHeight constrói só as linhas que o viewport consegue mostrar, mais uma margem de cada lado para que uma rolagem de uma linha não construa nada. Acima e abaixo da janela fica um espaçador cada, então o conteúdo continua tão alto quanto o arquivo e a barra de rolagem diz a verdade.

Os dois números vêm do layout, por dois canais novos no ScrollView:

new ScrollView(content)
{
    OnScrolled = offset =>,          // onde ELA ESTÁ, sempre que isso muda
    OnViewportChanged = height =>,   // quão alta ela acabou sendo
}

Eles são o canal de saída para o canal de entrada do Offset, e são o que torna qualquer lista longa possível: sem eles o deslocamento vive no host e nenhum componente consegue perguntar. O primeiro frame não tem nenhum dos dois e constrói tudo, o que está certo para um trecho; o segundo sabe os dois e estreita.

A metade web

O CodeSurface rebaixa para uma div focável com o cursor e as faixas de seleção como filhos posicionados de forma absoluta, e o keydown dela chama o MESMO CodeKeymap.Handle que o host do macOS chama. O controller, o documento, os tokenizadores e o histórico de desfazer por baixo dela são saída do eqc a partir do mesmo C#. Nada no caminho do browser reimplementa um comportamento de editor, que é o único jeito de os dois alvos não conseguirem divergir.

O code-editor.spec.ts dirige a superfície do jeito que um browser dirige: um keydown com flags de modificador, um pointerdown com coordenadas de cliente. Ele é a prova write-once do editor: todo comportamento que o host nativo afirma é exercitado no caminho web também.

O que o editor inclui

Camada
Documento, posições, intervalos
Tokenizadores (C#, TS/JS, Python, JSON, XML, texto)
Realçador incremental
Desfazer/refazer com junção
Controller: digitação, pares, indentação, comentário, movimento, busca, casamento de delimitadores
Contratos de IDE: completação, hover, dobras, diagnósticos, decorações, calha
Componente CodeBlock (pixels somente leitura, calha, marcadores, decorações)
MeasureText / MonoAdvance no contexto (nos dois alvos)
Componente CodeEditor (cursor, seleção, teclado, mouse)
CodeKeymap, um mapeamento de teclas que os dois alvos chamam
A superfície web, dirigida pela spec própria (code-editor.spec.ts)
Busca (⌘F), casamento de delimitadores, decorações por intervalo
Virtualização: uma janela sobre as linhas, os dois números vindos do layout

O modelo, a superfície e os comportamentos de acabamento estão cobertos em eQuantic.UI.Native.Engine.Tests (CodeModelTests, CodeEditorControllerTests, CodeEditorSurfaceTests, CodeEditorFinishTests). Todo comportamento acima é afirmado lá, que também é o melhor lugar para ler o que o editor promete.


Relacionado

  • Design System: a escala de tipo (incluindo a face mono) e a paleta de tokens com que o editor colore.
  • Componentes write-once: como a camada de componentes acima deste modelo alcança os dois alvos.

Clone this wiki locally