Skip to content

CodeEditor

Edgar Mesquita edited this page Oct 10, 2026 · 31 revisions

Code Editor

🌐 This page in: English · Português

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. The model lives in eQuantic.UI.Code, an assembly of its own between Primitives and Components that the SDK brings with them: pure logic with 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. Primitives keeps only the CodeSurface node and the protocol a realizer talks to the editor through (ICodeSurfaceModel), so a realizer knows nothing about the engine and the engine nothing about any realizer.

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 control 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 + cell × columnWidth), which only works while EVERY part of the editor agrees on those numbers. Four 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.
  • One map from a column to its cell (CodeLineCells). A document column counts UTF-16 units and the grid counts cells, and the two part at a tab, a wide character or an emoji. The caret, the selection, the click, the arrows, Backspace and the drawing all read the same map: see Columns are not cells.

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.
CodeLineCells Where one line lands on the grid: the cell each text element begins at, and the line's width in cells.
ICodeSurfaceModel What a realizer may tell the editor (a key, some text, a composition, the pointer, the clipboard, focus) and ask it (the carets to paint). In Primitives, beside CodeSurface.

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 or raw string (@"…", """…""", interpolated or not), 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,
};

And its words, IReadOnlyList<string> Keywords: the reserved words, the built-in types and the constants, which a completion offers wherever a word starts. A language derived from CurlyBraceLanguage gets them from the tables it colours with (ReservedWords, TypeWords, ConstantWords), so the colours and the completion read one list. Since 0.2.0-preview.61.


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 component 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; a closing bracket typed where only indentation stands before it steps back one level, to the block it closes; Backspace in leading whitespace removes a whole step; Tab goes to the next stop, not a fixed number of spaces.
  • A selection survives its edit: Tab, ⇧Tab and ⌘/ leave the lines they indented or commented selected, so pressing again does the same again.
  • 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; typing over a selection is one step with what replaced it, and a paste is a step of its own; 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. The inserted text is the text the document holds, its lines broken where the document breaks them: a paste that held CR or CRLF arrives with LF, and undo after it restores the document exactly.


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<CodeCompletionList> CompleteAsync(CodeDocument document, CodePosition position,
        CodeCompletionContext context, CancellationToken cancellation);
    Task<CodeCompletionItem> ResolveAsync(CodeCompletionItem item, CancellationToken cancellation) =>
        Task.FromResult(item);
}

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.

Completion

Since 0.2.0-preview.61. CodeEditorController.Completion is the editor's completion: the providers it asks, and the list while one is open, as a model a test drives with keystrokes and any host draws. CodeEditor draws the list (below); an IDE that draws its own reads IsOpen, IsLoading, Items, Selected and Start, and listens to Changed.

var editor = new CodeEditorController(source, CodeLanguages.CSharp);
editor.Completion.Providers.Add(languageService);                   // what an IDE knows
editor.Completion.Providers.Add(new CodeKeywordCompletionProvider()); // the language's own words
editor.Completion.Providers.Add(new CodeWordCompletionProvider());    // the words already in the file

A controller starts with no provider, and with none no list opens and no key goes to one: a list nothing draws must never take Enter. CodeEditor hands its controller the language's words and the document's unless it is told otherwise (Completions).

CodeWordCompletionProvider answers before the keystroke returns, so it reads the lines nearest the caret first, and no more than 50,000 characters of them, the caret's own line around the caret: a word started costs the same in a file of any length, and what a long file (or a minified line) leaves out is what lies farthest from the word being typed.

When it asks

A list asks its providers once, when a word starts, and filters what they answered on every keystroke after that, here and at once: a language service is asked per word, never per key.

What Asks For the word that starts
One of a provider's TriggerCharacters (the . after a name) that provider alone after the character
A word started by typing, outside a comment or a string, and not a number every provider at the word's start
⌃Space (Ctrl+Space on Windows and Linux) every provider at the word before the caret, or at the caret

A provider that answers IsIncomplete is asked again whenever the word changes, with the trigger Incomplete; the others keep their answers. An answer that arrives after its word was left, or after a newer request, is dropped, and its request's token is cancelled. An answer that finishes on another thread is applied on the UI thread, as SetState is.

The keys, while a list shows

Key Does
↓ ↑ Walk the list, round from the last entry to the first. With one entry showing, they move the caret.
PageDown PageUp A page (PageSize), stopping at either end.
Tab Accept the selected entry.
Enter Accept it when that changes the text; a word typed out in full ends its line instead.
Escape Close the list, and only the list: the trap on Tab stays armed and a dialog around the editor stays open.
A commit character (CommitCharacters) Accept, then type the character: Console. takes Console and goes on to its members.

Every other key, ⌃Space and the list's own among them, arms the trap on Tab again: after Escape, ⌃Space and Escape, Tab still indents.

Accepting writes over the word typed, or over the provider's own range (Replacing), whose end keeps its distance from the end of the line: what is typed or deleted at the caret moves it, and a move of the caret does not. It leaves the caret after the text, and is one undo step. The list closes when the caret leaves the word (another line, before its start, a selection, a character that ends a word), when the editor loses the keyboard and when its document is replaced.

Ranking

CodeFuzzyMatch is the filter, and it is public for anything else that narrows as a person types: the pattern's characters in order, case aside, the first where a part of the word starts (bc finds BarChart, col finds Column, lum finds nothing), and the best way to lay one over the other, a run above the same characters apart. Equal matches go by the provider's SortText, then the label. Among the best matches, an entry a provider marks Preselect is selected. Two providers offering one entry (one label, one inserted text) list it once, as the first provider's copy that matches the word.

Writing a provider

public sealed class ColumnNames(IReadOnlyList<string> columns) : ICodeCompletionProvider
{
    public Task<CodeCompletionList> CompleteAsync(CodeDocument document, CodePosition position,
        CodeCompletionContext context, CancellationToken cancellation) =>
        Task.FromResult(new CodeCompletionList(
            columns.Select(name => new CodeCompletionItem(name, CodeCompletionKind.Field)).ToList()));
}

The contracts are the Language Server Protocol's, typed, so an LSP client is an adapter: the context's Typing trigger is LSP's Invoked, IsIncomplete its isIncomplete, Replacing its edit range, ResolveAsync its completionItem/resolve, and the token its $/cancelRequest. CodeCompletionKind has every kind LSP has.

A provider's failure is its own: one that throws, whose answer faults, or whose callback on the token throws when the list cancels it (a connection that dropped) is reported through Failed, and the list goes on with what the others offered. No keystroke fails for it. An OperationCanceledException is the request's own cancellation only when the list cancelled it, and the provider's failure otherwise.

The list CodeEditor draws

Since 0.2.0-preview.61. While its completion shows a list, CodeEditor draws it at the word it completes, through the code surface and in the code's own coordinates, so the list moves with the code as the code scrolls.

new CodeEditor(source, "csharp")
{
    Height = SizeValue.Fill,
    // What it completes from, in this order. Null, the default, is the language's words and the
    // document's; an empty list is no completion at all.
    Completions = [languageService, new CodeKeywordCompletionProvider(), new CodeWordCompletionProvider()],
}

A read-only editor completes nothing, whatever it is given, and turning an editor read-only closes the list it shows and drops an answer still on its way. The editor hands the providers to its controller once, and again only when the list holds other providers (a list the parent changed in place included), so a parent that rebuilds with the same ones changes nothing, and a list already showing goes on with the providers it was asked of. It takes out only what it put in: a provider an app added to Editor.Completion.Providers itself stays beside them.

  • Where it stands. One line under the word, with its labels lined up with what was typed. When a page of rows does not fit below inside the editor's viewport and fits above, it stands over the line; when neither side holds a page, it takes the roomier one and shows as many rows as fit, never fewer than one. Its right edge stays inside the viewport. An editor that hugs its code has the code's height as its viewport, so a two-line editor shows a short list; a bounded editor's code fills its viewport, so a short file in a tall pane has the pane's room.
  • What a row says. The rows are the code's own lines, the editor's line height in its code face: the entry's kind as a letter, in the colour the code gives what it names, its label with what the word matched in the accent colour, and its detail, muted. The selected entry is washed, and its documentation shows under the list (over it, when the list stands above the line) once its provider resolved it, as up to four lines of plain text. It takes the room the rows leave on that side and no more, as many of its lines as fit or none: the rows never yield to it, so the list does not jump as the selection moves between entries with and without documentation. A row longer than the list cuts its detail first and its label after it, each ending in an ellipsis, counting the cells the code's grid gives each character (a wide character or an emoji takes two) and never cutting inside one, and its name for assistive technology keeps both whole.
  • A page. The list shows up to twelve rows and builds only those, the first following the selection past either end. PageUp and PageDown step by the rows shown, and a mark on the right says where the page is in the whole.
  • A press on a row accepts it, and the keyboard stays in the code (Pressable.CanRequestFocus). A press on the list that no row takes moves no caret.
  • Assistive technology. On the web the list is the code input's listbox: while it shows, the input says aria-autocomplete="list", names the list in aria-controls and the selected option in aria-activedescendant. On Photon the rows announce as options after the code field, the selected one selected.
Letter Kinds
m method, function, constructor
v field, variable
p property
e event
C S I E T N class, struct, interface, enum, type parameter, module
c constant, enum member
# value, unit, colour
k keyword
s snippet
o operator
r f d reference, file, folder
a text (a word of the document)

Fenced, on purpose: a wheel over the list scrolls the code and not the list, which the arrows and the page keys walk; the documentation is plain text until the hover card renders Markdown; and on Photon the list stands one border's width off its web twin until a bordered box keeps its child inside the border (#629), while a row's touch margin can take a press meant for the row above it (#630): 13dp of it under a finger, and 3dp under a pointer, whose target keeps a 24dp floor.


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.
Standalone Whether the block is the whole component (its own slab, its own viewport) or bare content something outside frames and scrolls. Default true; CodeEditor sets it false.
ViewportWidth How wide the viewport turned out to be, handed back from layout. The content is never narrower than this and never wider than it needs to be.

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.

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, so both answer with 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")
{
    Height = SizeValue.Fill,        // as tall as its place: an IDE's pane
    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.

OnChanged is raised for an edit and OnSelectionChanged for a move, each only when its own thing changed: an arrow is not an edit, and a status bar listens to the second. Both were raised together for anything the surface did, so an app that re-read the document on every change did it for every arrow.

One keymap, two surfaces, two traditions

CodeKeymap.Handle(editor, key, modifiers, convention, 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: a Photon host from its key event, the browser from its keydown. Nothing about what ⌥← or ⇧Tab means is decided in a realizer.

Since 0.2.0-preview.58

It speaks both keyboard traditions, because they disagree on exactly the keys an editor lives on: ⌘← is "line start" on a Mac and Ctrl+← is "one word back" everywhere else. The host says which one its users live in (KeyboardConvention.Apple on macOS and iPadOS, Standard everywhere else), and KeyModifiers.Command is ⌘ on the one and Ctrl on the other. A keymap that knew only Apple's had Windows and Linux users jumping to the start of the line every time they meant to step over a word.

Key Apple Standard
← → a character; ⌥ a word; ⌘ the line's edge a character; Ctrl a word
↑ ↓ a line; ⌘ the document's edge a line (Ctrl+↑ and Ctrl+↓ scroll a view, and are left to the host)
Home / End the line's edge; ⌘ the document's the line's edge; Ctrl the document's
PageUp / PageDown a page a page
⇧ + any of them extends from the anchor extends from the anchor
Enter a new line, inheriting the indentation (one level more after {) the same
Tab / ⇧Tab indent / outdent: the selection, or to the next tab stop the same
Backspace / Delete a character; ⌥ a word; ⌘⌫ the line up to the caret; leading whitespace goes a whole step a character; Ctrl a word
undo / redo ⌘Z / ⇧⌘Z, and ⌘Y Ctrl+Z / Ctrl+Shift+Z, and Ctrl+Y
select all, copy, cut, paste ⌘A ⌘C ⌘X ⌘V; a copy with nothing selected takes the line Ctrl+A, C, X, V, the same
comment ⌘/ toggles line comments (nothing in a language that has none) Ctrl+/
Escape releases Tab (below) the same

Tab is the editor's, and Escape gives it back. An editor takes Tab, which is the point, and a keyboard user must still be able to leave it. Escape releases the trap, so the next Tab moves focus on instead of indenting, and any other key sets it again. Escape itself is left unclaimed, so it still means what it means around the editor (it closes the dialog the editor sits in), and on Photon it also takes the keyboard out of the editor. A read-only editor never takes Tab at all. Inside a dialog it is the same: the dialog cycles only a Tab the editor did not take, so an editor at either end of a dialog still indents, and after Escape the next Tab moves on as the dialog cycles.

Text comes from the platform, never from a key. What a keystroke produces is the platform's business (a dead key, an input method, AltGr on a European layout, dictation, "á" from three events), so text arrives as a string and goes to HandleText, where auto-closing pairs and the step-over-the-closer rule live. An input method's composition is IN the document while it is being built, underlined in the code's own ink, and commits as one edit; cancelling it leaves the document as it was. The clipboard is the platform's too: on the web the copy keys are left to the browser's own copy, cut and paste events, which carry the text, so a paste from another application lands.

Geometry is arithmetic

The face is monospaced, so a (line, cell) IS (contentTop + line × lineHeight, contentLeft + cell × columnWidth), and 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. The engine does the arithmetic (editor.Grid holds the metrics), so a realizer paints rectangles it is handed and never turns a column into pixels.

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. The component draws the bands under the text, in the code's own layers, so they look the same on every target; a realizer paints only the carets, which have to blink.

Columns are not cells

Since 0.2.0-preview.58

A document COLUMN is an offset in the line's UTF-16 text, and a CELL is one advance of the mono face on screen. They are the same number only while every character is one unit long and one cell wide, and three things are not:

  • A tab runs to the next stop, a multiple of the language's indent width: with stops of 4, \tx puts x in cell 4.
  • A wide character (East Asian wide or fullwidth, and an emoji) takes two cells.
  • A text element (an extended grapheme cluster: 👍🏽, a flag, e with a combining accent) is never split: its marks, joiners, modifiers and the second half of a surrogate pair take no cell of their own, and no caret stops inside it.

CodeLineCells is the one map from each to the other, and everything reads it: the caret and the selection are placed through it, the block draws the cells it names, a click is its inverse (a point in the left half of a tab lands before it, in the right half after it), the arrows and Backspace step by element, and word steps and double clicks select whole elements. On the web the elements come from Intl.Segmenter, which splits text as .NET's StringInfo does. Which characters are wide is a table the engine keeps, and it is compared, on both sides and for every code point, with the terminal convention the SDK's embedded Bun follows (CellWidthOracleTests).

One coordinate space

Since 0.2.0-preview.21

The marks are drawn against the SURFACE that holds them, so nothing may scroll inside it. The viewport lives OUTSIDE CodeSurface (the editor builds it), and the surface travels with the code:

Stack (the layers, always there)
 ├ Box (the slab, clipped)
 │  └ ScrollView (vertical, when the editor is bounded)
 │     └ Row
 │        ├ the gutter      ← beside the sideways scroll, inside the vertical one
 │        └ ScrollView (horizontal)
 │           └ CodeSurface  ← moves with the code, so the marks do
 │              └ CodeBlock ← Standalone = false: bare content, no slab, no viewport
 ├ the corner (the caption)
 └ the find bar, while it is open

The code is the FIRST layer whether or not anything is over it. Opening find used to wrap the code in a Stack it did not have before, and a surface that moves in the tree is a new surface to every host: the scroll went back to the top, "next" revealed nothing, and on Photon the keyboard pointed at a path nothing had any more.

Getting this wrong is not subtle once you look for it, and is invisible until you do: a block that scrolls INSIDE the surface puts the code in one space and the caret in another. Scroll a long line sideways and the text travels while the caret stays behind; click, and the column is read as though nothing had scrolled. Putting the viewport outside makes every one of those sums true by construction: there is nothing left to keep in sync.

Two consequences worth stating, because each was a bug:

  • The content is the VIEWPORT's width, never less than the longest line. Filling alone is why the sideways scroll never scrolled: a scroll view whose content is exactly its own size has nothing to move. Sizing to the code alone is the opposite mistake: a click in the empty space to the right of a short line would land on nothing.
  • The width comes back FROM layout (ViewportWidth), the way the height already did. The two targets disagree about what filling means inside a sideways scroll view (a page resolves 100% against the scroller, Photon measures the content unbounded on the scroll axis), and a reported number is the one arithmetic both realizers agree on.

The caret comes back into view

Since 0.2.0-preview.22

Arrowing off the edge of a long line, or down past the last visible one, used to leave the caret where the arithmetic put it: outside the box. Two things have to be right, and each is easy to get wrong in a way that looks implemented:

  • Which element. Every keystroke rebuilds the tree, so the surface the handler ran on is detached by the time anything runs afterwards, and scrollIntoView on a detached caret succeeds in silence. The surface carries its path (data-eq-code), and the reveal resolves through it.
  • When. The render flushes on an animation frame, so a microtask finds a caret that has not moved yet and correctly decides it is already on screen. It waits for the frame after the flush, and ALSO for a timeout, the same pair the render scheduler keeps, because a hidden or throttled tab stops delivering frames and the render happens anyway.

The gutter stays where it is

Since 0.2.0-preview.25

The numbers are a column of their own, BESIDE the sideways scroll and inside the vertical one: they travel down the file with the code and stay put as it slides across.

Two other arrangements were tried first and both were wrong in the same way. Inside the scroll, the numbers left with the code and a reader lost the number of the line they were reading. Overlaid on top of it, the code slid UNDER an opaque column and real characters disappeared: using became eQuantic.UI.Core;, which looks like a rendering bug and is actually a layering one.

Beside it, both stay true and neither compensates for the other. The price is stated in the metrics:

ContentLeft  =  the code's own left padding          ← where column 0 begins
             ≠  gutter + padding                      ← what it used to be

That one line is why this took a deliberate change rather than a tweak. Column zero is where the caret, the selection band and every decoration start counting, so moving its origin moves all three at once, which is exactly why it is one property and not three, and why they could all be moved in one edit. The GAP between the numbers and the code belongs to the gutter now, not to the code: padding inside the scroll slides away, and the digits ended up touching the first character.

Finding, matching, and files too long to build

Find

⌘F (Ctrl+F) opens a bar over the top-right corner, over and 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.

Since 0.2.0-preview.58

  • The field takes the keyboard when the bar opens, and keeps it: Enter and the chevrons step through the matches, and the count reads 3/17. It counts what ITS field looks for, so an empty field shows no count.
  • A step selects the match, which reveals it, and the app hears the move on OnSelectionChanged, as it does when a key moves the caret.
  • Escape closes the bar wherever the keyboard is, in the bar or in the code, and gives the keyboard back to the code.
  • The close button is named in the interface's language (SdkStrings.CloseFind).

An IDE with its own find UI skips all of it and sets Search / SearchMatchCase directly: its matches are marked while the editor's own bar is closed or its field empty.

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 BOUNDED CodeEditor 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.

Since 0.2.0-preview.58

Height says how tall the editor is. Hug, the default, is as tall as the code, up to MaxHeight when one is set; Fill takes the height its place gives it, which is how an IDE's pane uses it; and a fixed height is that many dp. Anything but an uncapped Hug is bounded, and a bounded editor with a MaxHeight takes its place's height up to the cap. Without a bound there is no viewport and no window: every keystroke built every line, 100 to 213 ms per key on a 3000-line file.

A bounded editor's code fills its viewport however short the file: a press under the last row puts the caret at the end of the document, as in any code editor, and a drag from there selects back to where it stops. A diff's fillers after its last line are rows too, and a press on one lands on that line at its column, as on any filler. Since 0.2.0-preview.61.

Fill follows the layout rule: in a parent with no bound of its own, a page that scrolls for instance, it is as tall as the code, on both targets. Give it a place with a height.

Everything else a build draws is windowed with the lines: the selection's bands, the matches of a search (found once per search, not per build) and every other mark are made for the lines in view only, and the widest line, which sets the code's width, is measured once per document. A scroll step over 50,000 lines with everything selected and a search on went from 81 ms to 3.

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. On the web the viewport is measured once the render has been written, and again whenever it changes size with no render at all (the window resized, a splitter dragged), so a Fill editor in a pane that grew builds the rows the pane now shows.

Asking for the keyboard

Since 0.2.0-preview.58

An IDE puts the keyboard back in the code after a panel over it closes, or when a file opens:

editor.Editor.RequestFocus();

The request belongs to the MODEL (ICodeSurfaceModel.FocusVersion), so whichever host draws the surface honours it, once: a request made before the first frame is honoured when the surface is drawn, and one already honoured is not honoured again when the surface is drawn somewhere else. The editor's own find bar uses it as it closes.

Autofocus is honoured once per MOUNT, as a browser does: a field or an editor that appears takes the keyboard from whatever held it, because it appeared because someone opened it, and one that leaves and comes back asks again.

On a server-rendered page

Since 0.2.0-preview.58

The server writes the surface (the code, its carets and its input), but it has no fonts: it measures every string as 0 wide. A component whose own Build measured text on the server is marked (data-eq-unmeasured), and hydration draws that subtree again on the client instead of adopting markup built on widths nobody measured. The gutter of a code block on a server-rendered page is as wide as its numbers because of it.

Comparing two texts

Since 0.2.0-preview.59

CodeDiffer answers what differs between two texts: the lines, and within each changed region the words. It is what a diff view draws and what an IDE reads to show a file against its last commit.

var changes = CodeDiffer.Compare(original, modified);   // two CodeDocuments
foreach (var change in changes)
{
    // OriginalCount lines from OriginalStart became ModifiedCount lines from ModifiedStart.
    // A count of 0 is an insertion, or a deletion, at that line.
    foreach (var inner in change.Inner)
    {
        // inner.Original became inner.Modified: the words, in each side's own positions.
    }
}

CodeDiffer.CompareLines(originalLines, modifiedLines);   // or two lists of lines

The line diff is Myers' shortest edit script, the one git computes with --minimal: as few lines removed and added as the two texts allow. The common head and tail are trimmed first, so the cost follows the size of the change rather than the size of the file. The word diff runs the same algorithm over the tokens of a changed region (runs of word characters, runs of whitespace, any other character by itself, and a surrogate pair whole), with a line break between two lines, so a line split in two reads as the break it is.

Two ranges too far apart to search, past 2,000 rounds, are marked whole: a longer script than the shortest, and still a right one. A count of rounds and not a clock, so .NET and the web stop at the same point and answer the same. A changed region with more than 20,000 tokens on a side is marked whole too, with no words, and it is known as soon as the limit is passed: the tokens past it are never built, so a line of a million punctuation marks costs what 20,000 do.

What pins it: the shapes a diff view shows, by hand; 500 random pairs against a longest common subsequence (the script rebuilds the modified text, and is as short as it can be); git's own --minimal counts on real files of the repository's history; and the twin in the served runtime, compared change by change, words included, with the C#.

The diff view

Since 0.2.0-preview.59

CodeDiff draws two versions of a text and what differs between them, as a review does. It is a write-once component: the same tree on the web and on Photon.

new CodeDiff(before, after, "csharp")
{
    OriginalCaption = "Invoice.cs (main)",
    ModifiedCaption = "Invoice.cs",
    MaxHeight = 460,
    OnChanged = text => Save(text),   // every edit of the modified side, with its whole text
}

// A change as git writes it: one file of a patch, which reads only.
CodeDiff.OfPatch(CodePatch.Parse(patchText)[0], "csharp")
  • Side by side, the two sides are level at every change: the side with fewer lines is padded, so the line after a change is on the same row on both. Inline (Inline = true, or the toolbar's switch), the lines a change removed are drawn between the lines that replaced them, with the original's numbers beside the modified's.
  • A changed line is washed across its row, and the words that changed inside it are marked, from CodeDiffer's inner changes.
  • A run of unchanged lines longer than Context (3 each side of a change) folds into one row that says how many lines it holds, and opens on a press. A step, a search or a caret that lands inside a fold opens it.
  • F7 and Shift+F7 step to the next and the previous change (VS Code's Alt+F5 too), and so do the toolbar's arrows, wrapping at either end. A step moves both carets, which is what reveals the change on both sides. The keys are the diff's own: of two diffs on a page, the one the keyboard is in steps.
  • Each side is numbered as its file is. A patch's sides are numbered as the patch says, and the lines a patch leaves out are a row with its hunk's header in their place.
  • The modified side EDITS when the diff is of two texts and not ReadOnly, and it is compared again after every edit. As with CodeEditor.InitialCode, Modified opens the document, and the document is the diff's own from the first keystroke. Editor is the modified side's controller, which an IDE drives like any editor's.
  • Both sides are in one vertical scroll, so they cannot drift apart, each with its own sideways scroll. Height is Hug (up to MaxHeight), Fill or fixed, and only the rows in view are built.

Under it, CodeBlock draws ROWS rather than lines when it is given a CodeRows map (Rows): a padding row (CodeFiller), a line of another document drawn between two of its own (FillerDocument), and a fold (CodeCollapse). A diff is one use of it, and a view of an app's own that needs rows its document does not have is another.

What pins it: the component on Photon's layout (CodeDiffComponentTests: the sides level, the inline order, a fold that opens on a press through the host's own dispatch, the steps and their wrap, an edit compared again, a patch's view, the keys of two diffs), the rows under it (CodeBlockRowsTests), and the sample's /diff page, walked in a browser.

The web half

CodeSurface lowers to a div with the carets as absolutely positioned children and a TEXTAREA at the primary caret. Every behaviour is the engine's, called through ICodeSurfaceModel: the controller, the document, the tokenizers and the undo history are eqc output from the same C#, and nothing in the browser path reimplements an editor behaviour, which is the only way the two targets cannot drift.

  • keydown carries only what the keymap CLAIMS as a command. A key an app's Shortcut claimed reaches no element's own handler, as on Photon.
  • Text arrives as input, never from keydown: beforeinput for typing, dead keys, AltGr and dictation; the composition events for an input method, whose text is drawn in the document while it is built and commits once; and the clipboard's own copy, cut and paste events. A surface that read characters off keydown lost every one of those.
  • The pointer is mapped by the engine: a press, a drag that extends the selection, Shift+click, double and triple click (the count comes from the press, which Chrome reports as 0 on pointerdown and only counts on mousedown).
  • The surface is found by its path (data-eq-code) whenever something has to happen after the render (a reveal, a focus request), because the element a handler ran on is replaced by it.

code-editor.spec.ts drives the surface the way a browser does: a keydown with modifier flags, an input event, 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 ✅
Both keyboard traditions (KeyboardConvention), and Escape releasing Tab ✅
Text from the platform: input methods, dead keys, the clipboard's own events ✅
Columns mapped to cells: tabs, wide characters, text elements whole (CodeLineCells) ✅
Height: Hug, Fill or fixed, with everything drawn windowed to the lines in view ✅
Focus requests (RequestFocus) and Autofocus once per mount ✅
Server rendering: the surface written by the server, redrawn where it could not measure ✅
Comparing two texts: lines and words (CodeDiffer) ✅ since 0.2.0-preview.59
The diff view: side by side and inline, folds, steps, editing, patches (CodeDiff) ✅ since 0.2.0-preview.59
Completion: the session, its filter, its keys, the language's and the document's words (CodeCompletion) ✅ since 0.2.0-preview.61
The completion list, at the word it completes (CodeEditor.Completions) ✅ since 0.2.0-preview.61

The model, the surface and the finishing behaviours are covered in eQuantic.UI.Native.Engine.Tests (CodeModelTests, CodeEditorControllerTests, CodeEditorSurfaceTests, CodeEditorFinishTests, CodeViewModelTests, CodeEditorComponentTests, CodeEditorPerfTests), and the width table on both sides in CellWidthOracleTests. 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