-
Notifications
You must be signed in to change notification settings - Fork 1
CodeEditor
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.
| 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. |
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.
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 dialectA 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,
};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.
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-
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.
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.
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. |
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 guessed —
context.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.
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.
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.
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.
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.
| 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, mouse) |
✅ shipped |
CodeKeymap — one key mapping both targets call |
✅ shipped |
| Virtualization for very large files | 🔜 next |
The model and the surface are covered by 60 tests in eQuantic.UI.Native.Engine.Tests
(CodeModelTests, CodeEditorControllerTests, CodeEditorSurfaceTests) — every behaviour above is asserted there, which is
also the best place to read what the editor promises.
- 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.