-
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.
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 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.
⌘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.
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.
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.
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.
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.
| 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.
- 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.