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.
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.
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á.
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 = [')', ']', '}'],
var highlighter = new CodeHighlighter(CodeLanguages.CSharp);
var tokens = highlighter.TokensFor(document, line);
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 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.
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:
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")
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),
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}",
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.
←→↑↓
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 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.
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:
└ 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.
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
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.
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.
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.
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:
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.
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.
Documento, posições, intervalos
✅
Tokenizadores (C#, TS/JS, Python, JSON, XML, texto)
✅
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.
•
Design System: a escala de tipo (incluindo a face mono) e a paleta de tokens com que o editor colore.