Skip to content

CodeEditor

Edgar Mesquita edited this page Aug 8, 2026 · 7 revisions

Code Editor

The SDK ships the model of a code editor, not just a syntax-highlighted box: a document made of lines, an incremental highlighter, an undo history that thinks in words, and a controller whose methods are the commands an IDE puts on its menus. Everything on this page lives in eQuantic.UI.Primitives — pure logic, zero dependencies, no pixels — which is what lets an app built on it unit-test its own editing commands without a screen, and what lets the same editor run as GPU pixels on native and as DOM on the web.

Why a model layer at all? Because "an editor" is 90% arithmetic: which column a caret lands on after ↓ through a short line, what Backspace does inside indentation, which brace matches which. Wire that into a widget and it can only be tested by clicking. Keep it here and it is tested by asserting.


One grid, and a caret you can see

The caret and the selection band are placed by arithmetic — contentTop + line × lineHeight, contentLeft + column × columnWidth — which only works while EVERY part of the editor agrees on those numbers. Three rules keep them agreeing, each of them a bug that shipped once:

  • One measurement. CodeEditor measures the grid and hands it to CodeBlock (Metrics), which never measures its own. Two measurements drift the moment the two halves are built with contexts that differ, and a caret then sits between lines.
  • The marks' ink rides the node (CaretColor / SelectionColor), not the realizer. An editor on an inverse slab writes with an ink of its own; painted from the page theme, a caret is invisible on exactly the surface people type into. Only the blink and the focus gate are stylesheet mechanics — 500ms per phase, the same the native host blinks from its own clock.
  • Measure with a font CSS can parse. FontWeight lowers to a member name; a canvas given regular 11.5px … keeps 10px sans-serif and answers with a proportional advance, silently.

The pieces

Type What it is
CodeDocument The text, kept as lines. Immutable: every edit returns a new document (that is what undo keeps).
CodePosition / CodeRange A line+column, and a directed pair (anchor → focus) so shift+arrow knows which end it is dragging.
ICodeLanguage A line-at-a-time tokenizer with carry-over state, plus the language's Rules.
CodeHighlighter The colours of a document, kept up to date incrementally.
CodeHistory Undo/redo that coalesces a run of typing into one step.
CodeEditorController Every editing command, on a document and a selection — the editor minus the pixels.

Documents are lines

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

Lines rather than one string because everything an editor does is line-shaped: the gutter numbers them, the tokenizer colours them one at a time, the caret moves between them, and a keystroke must not re-copy a megabyte.

One edit primitive — replace a range with text — covers insert (empty range), delete (empty text) and typing over a selection (both):

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

Clamp pins any position inside the document, which is why no navigation has to think about the edges. LineStart implements the Home every editor has: the first non-blank character, and only column zero when the caret is already there.


Languages

Included: C#, TypeScript/JavaScript, Python, JSON, XML (and .csproj/.plist with it), and plain text as the fallback that always renders.

var language = CodeLanguages.For("cs");          // by name or extension; PlainText when unknown
CodeLanguages.Register("sql", new SqlLanguage()); // an app brings its own dialect

A tokenizer reads one line and returns the state the next line starts in:

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

That shape is what makes re-colouring a keystroke cheap, and it is the only way a construct that spans lines can work at all — a block comment, a C# verbatim string, a JS template literal, a Python docstring. Token kinds are a small closed set (Keyword, Type, String, Number, Comment, Operator, Punctuation, Function, Attribute, Property, Constant, Plain), because a design system has one palette for code.

Each language also declares its rules, and every behaviour is built from them:

public CodeLanguageRules Rules { get; } = new()
{
    LineComment = "#",                       // ⌘/ ; null = the command does nothing (JSON)
    IndentAfter = [':', '(', '[', '{'],      // what opens a level (Python indents after a colon)
    OutdentOn  = [')', ']', '}'],
    IndentWidth = 4,
    InsertSpaces = true,
};

Incremental highlighting

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

// after an edit
int repaintThrough = highlighter.LineChanged(document, line);

LineChanged re-tokenizes that line and keeps going only while the ending state keeps coming out different — which happens when a block comment or a multi-line string opens or closes, and in no other case. It returns how far the colours moved, so a caller can repaint just that.


The controller

CodeEditorController is the editor's behaviour. An IDE drives it from its own key map, its own menu or its own language server; the widget is only what draws it.

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

editor.Type('(');                 // auto-closes, caret lands inside
editor.InsertNewLine();           // inherits indentation, opens a block, drops the closing brace
editor.Indent();                  // caret → next tab stop; selection → every line
editor.ToggleLineComment();       // ⌘/ — adds, or removes when all lines already are
editor.Move(CodeMotion.Line, CodeDirection.Forward, extend: true);
editor.Undo();  editor.Redo();
editor.FindNext("needle");
editor.MatchingBracket(editor.Caret);
editor.Apply(range, "renamed");   // a refactor: undoes like anything typed

The behaviours you get for free

  • Pairs — an opening bracket closes itself; typing the closing half over the auto-inserted twin steps over it instead of doubling it; deleting the opening half takes the closer with it; a quote inside a word stays an apostrophe (don't).
  • Indentation — a new line inherits the current indent and gains a level after {; Enter between {} opens the block and drops the closer to its own line; Backspace in leading whitespace removes a whole step; Tab goes to the next stop, not a fixed number of spaces.
  • Movement — a run of ↓ through ragged lines remembers the column it started from; word steps stop where a reader would; a plain → collapses a selection to its edge.
  • Undo — a run of typing is one step; moving the caret ends the run; a new edit kills the redo branch.

Events

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

Changed carries the CodeEdit — range, removed text, inserted text, selection on each side. An IDE subscribes to edits, not keystrokes, because a paste and a refactor are edits nobody typed.


Extension points for an IDE

These are contracts the app implements; the editor's job is to place what they return.

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); }

Asynchronous because the answer usually crosses a process boundary, and an editor that blocks on it is an editor that stutters. IndentationFoldProvider is the default fold provider: it works for every language, including the ones nobody wrote a parser for.

Data an app hands in per frame:

Type For
CodeDiagnostic The squiggle under the code and the row in the problems list — one record, Range + Severity + Message (+ Code, Source).
CodeDecoration Any extra mark over a range: search matches, the symbol under the caret, a matching bracket, a diff hunk. Highlight/Squiggle/Outline/Strike.
CodeGutterMarker Breakpoints, git status, the statement a debugger stopped on.

CodeBlock — the read-only surface

The model draws through one component. Every line becomes a Row of coloured Text runs, which is why it needs no engine support beyond the monospaced face: the same tree renders as GPU pixels and as DOM.

new CodeBlock(source, "csharp")
{
    ShowLineNumbers = true,
    FirstLineNumber = 120,          // a fragment quoted from line 120 says 120
    MaxHeight = 320,                // caps the height and scrolls past it
    ActiveLine = 4,                 // the debugger's current line
    GutterMarkers = [new CodeGutterMarker(4, CodeGutterKind.Breakpoint)],
    Decorations  = [new CodeDecoration(range, CodeDecorationKind.Search)],
    OnGutterPressed = line => ToggleBreakpoint(line),
    OnCopy = () => clipboard.Write(source),
    Caption = "Program.cs",
}
Property What it is for
Inverse A dark slab in BOTH modes — code as a figure in documentation, not a control.
Highlighter Reuse one across frames so colouring stays incremental (an editor does; a snippet does not need to).
Size The code's own size; the gutter follows it.

Two rules the component keeps that are easy to get wrong:

  • The gutter is MEASURED, not guessedcontext.MeasureText(lastNumber + "0", style). A file with 1000 lines needs a column a file with 10 does not.
  • Long lines scroll sideways, never wrap. A wrapped line of code has lost the one thing its indentation was telling you.

Measuring is part of the context

ComponentContext.MeasureText(text, style) and MonoAdvance(style) answer how wide a string WOULD be, in dp, before it is laid out. Native asks the platform text service; the web asks the browser through a canvas 2D context using the same font stacks the CSS uses — the same numbers each target will lay the text out with, which is what mapping a click to a column depends on.

CodeEditor — the editable surface

The same drawing, plus the three things that make it an editor: a caret, a selection, and a keyboard.

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,
}

The component owns a CodeEditorController and hands it to a CodeSurface node. An IDE reaches for editor.Editor to run commands nobody typed — a formatter, a rename, a language server's edit — and they undo like anything else, because they go through the same primitive.

One keymap, two surfaces

CodeKeymap.Handle(editor, key, modifiers, clipboard) is where a key NAME becomes a command. It is plain C#, so it transpiles with everything else and both surfaces call the same function: the macOS host from its keyDown, the browser from its keydown. Nothing about what ⌥← or ⇧Tab means is decided in a realizer.

Key What it does
←→↑↓ character / line; steps by word, goes to the line's edge
⌘↑ ⌘↓ ⌘Home ⌘End the whole document
+ any of them extends from the anchor
Enter new line, inheriting the indentation (one level more after {)
Tab / Tab indent / outdent — the selection, or to the next tab stop
Backspace / Delete a character; takes the word; leading whitespace goes a whole step
⌘Z / ⇧⌘Z undo / redo, coalescing a run of typing into one thing
⌘A ⌘C ⌘X ⌘V select all, copy, cut, paste — copy with no selection takes the line
⌘/ toggle line comment (nothing in a language that has none)
Escape LEAVES the editor — one that traps Escape is one you cannot get out of

Typed characters do not go through the keymap: what a keystroke produces is the platform's business (a dead key, an input method, "á" from three events), so text arrives as a string and goes to Type, where auto-closing pairs and the step-over-the-closer rule live.

Geometry is arithmetic

The face is monospaced, so a (line, column) IS (contentTop + line × lineHeight, contentLeft + column × columnWidth) — a caret repaints on every keystroke without measuring anything or re-laying-out. Both realizers use the same numbers, and CodeBlock.MetricsFor is the single place they come from; two independent calculations would drift by a pixel and then by a character.

A selection is one BAND PER LINE, never one rectangle over the range: a single rectangle would cover the indentation of lines the range never touched.

Finding, matching, and files too long to build

Find

⌘F opens a bar over the top-right corner — over, not above: code that jumps when you open find has lost the line you were looking at. Every match is washed and the CURRENT one is outlined, because "next match" that moves something invisible has told you nothing. Enter and the chevrons step through; the count reads 3/17.

An IDE with its own find UI skips all of it and sets Search / SearchMatchCase directly.

Brackets

MatchBrackets (on by default) outlines the bracket the caret is against and the one it pairs with. A caret sits BETWEEN characters, so it belongs to the bracket on either side of it, and the one BEHIND wins — having just typed ), that is the one you mean.

Both marks are CodeDecorationKind.Outline, not a wash: a wash would hide the character the mark is pointing at.

Decorations are ranges

A decoration is a RANGE, and it draws as one rectangle per line it spans — the same arithmetic the selection band uses.

Kind What it draws
Highlight a background wash — a search match, a symbol under the caret
Outline a box around the range — a matching bracket
Squiggle a rule under it — a diagnostic
Strike a rule through it — deleted in a diff, unreachable code

On an Inverse slab every one of them takes the DARK half of its colour, for the same reason the tokens do: a light-mode token over dark code reads as a rendering fault.

Virtualization

A CodeEditor with a MaxHeight builds only the lines the viewport can show, plus a margin either side so a scroll of one line builds nothing. Above and below the window sits one spacer each, so the content is still as tall as the file and the scrollbar tells the truth.

Both numbers come from layout, through two new channels on ScrollView:

new ScrollView(content)
{
    OnScrolled = offset =>,          // where it IS, whenever that changes
    OnViewportChanged = height =>,   // how tall it turned out to be
}

They are the out channel to Offset's in channel, and they are what makes any long list possible — without them the offset lives in the host and no component can ask. The first frame has neither and builds everything, which is right for a snippet; the second knows both and narrows.

The web half

CodeSurface lowers to a focusable div with the caret and the selection bands as absolutely positioned children, and its keydown calls the SAME CodeKeymap.Handle the macOS host calls. The controller, the document, the tokenizers and the undo history underneath it are eqc output from the same C# — nothing in the browser path reimplements an editor behaviour, which is the only way the two targets cannot drift.

code-editor.spec.ts drives the surface the way a browser does: a keydown with modifier flags, a pointerdown with client coordinates. It is the write-once proof for the editor — every behaviour the native host asserts is exercised on the web path too.

What the editor includes

Layer
Document, positions, ranges
Tokenizers (C#, TS/JS, Python, JSON, XML, text)
Incremental highlighter
Undo/redo with coalescing
Controller: typing, pairs, indent, comment, motion, find, bracket match
IDE contracts: completion, hover, folds, diagnostics, decorations, gutter
CodeBlock component (read-only pixels, gutter, markers, decorations)
MeasureText / MonoAdvance on the context (both targets)
CodeEditor component (caret, selection, keyboard, mouse)
CodeKeymap — one key mapping both targets call
The web surface, driven by its own spec (code-editor.spec.ts)
Find (⌘F), bracket matching, ranged decorations
Virtualization — a window over the lines, both numbers from layout

The model, the surface and the finishing behaviours are covered in eQuantic.UI.Native.Engine.Tests (CodeModelTests, CodeEditorControllerTests, CodeEditorSurfaceTests, CodeEditorFinishTests) — every behaviour above is asserted there, which is also the best place to read what the editor promises.


Related

  • Design System — the type scale (including the mono face) and the token palette the editor colours with.
  • Write-Once Components — how the component layer above this model reaches both targets.

Clone this wiki locally