Skip to content

CodeEditor

Edgar Mesquita edited this page Aug 5, 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.


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

Shipped: 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.

Status

Layer State
Document, positions, ranges ✅ shipped
Tokenizers (C#, TS/JS, Python, JSON, XML, text) ✅ shipped
Incremental highlighter ✅ shipped
Undo/redo with coalescing ✅ shipped
Controller: typing, pairs, indent, comment, motion, find, bracket match ✅ shipped
IDE contracts: completion, hover, folds, diagnostics, decorations, gutter ✅ shipped
CodeBlock component (read-only pixels, gutter, markers, decorations) ✅ shipped
MeasureText / MonoAdvance on the context (both targets) ✅ shipped
CodeEditor component (caret, selection, keyboard) 🔜 next
Virtualization for very large files 🔜 next

The model is covered by 48 tests in eQuantic.UI.Native.Engine.Tests (CodeModelTests, CodeEditorControllerTests) — 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