Repository navigation
CodeEditor
🌐 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.
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.
CodeEditormeasures the grid and hands it toCodeBlock(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.
FontWeightlowers to a member name; a canvas givenregular 11.5px …keeps10px sans-serifand 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.
| 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. |
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 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.
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 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-
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.
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.
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. |
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 fileA 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.
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.
| 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.
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.
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.
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 inaria-controlsand the selected option inaria-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.
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.
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.
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.
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.
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.
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,
\txputsxin 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,
ewith 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).
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.
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
scrollIntoViewon 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.
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.
⌘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.
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 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.
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.
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.
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 linesThe 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#.
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 withCodeEditor.InitialCode,Modifiedopens the document, and the document is the diff's own from the first keystroke.Editoris 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.
Heightis Hug (up toMaxHeight), 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.
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.
-
keydowncarries only what the keymap CLAIMS as a command. A key an app'sShortcutclaimed reaches no element's own handler, as on Photon. -
Text arrives as input, never from
keydown:beforeinputfor 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 owncopy,cutandpasteevents. A surface that read characters offkeydownlost 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
pointerdownand only counts onmousedown). -
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.
| 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.
- 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.
🌐 English · Português
🏁 Start here
🏗️ Architecture
- Architecture Overview
- Write-Once Components
- Declarative Surface
- Package Architecture
- Components
- Styling
- Localization
- Analytics & GTM
📱 Write-once
⚙️ Compilation
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development