Documentation

Every type in AvaCodeEditor, what problem it solves, and the smallest piece of code that uses it. Short on purpose — if a paragraph here is not telling you what to type, it is telling you why you would.

What this is

AvaCodeEditor is one Avalonia control that shows code. You give it text; it draws the text, a caret and a selection, and lets people type. That is the whole of what it does by itself.

Everything else — line numbers, folding, blame, breakpoints, diff colours, a right-click menu — is something you plug in, through small interfaces. The editor never learns what git is, what a diff is, or what your language means. That is deliberate: it is why the same control can be a merge tool's pane, a debugger's window and a log viewer without growing a mode for each.

  • Fast, because it is arithmetic. Every character sits in one cell of a fixed grid, so "which character is under this pixel?" is a division, not a search.
  • Big files are free. Opening a document reads only the lines about to be drawn — a million-line file opens in milliseconds.
  • Nothing to subclass. You implement an interface and hand it over.

Install it

Two packages. The first is the control; the second is highlighting, and is optional — without it you get plain, uncoloured text.

dotnet add package AvaCodeEditor
dotnet add package AvaCodeEditor.Highlighting

The package's major version tracks Avalonia's: 12.x.y is built for Avalonia 12 on .NET 10.

Your first editor

Put the control in a window and give it a document. Three lines, and you have a working editor with a caret, selection, undo and clipboard.

<Window xmlns:editor="using:AvaCodeEditor">
  <editor:CodeEditor x:Name="Editor" />
</Window>
using AvaCodeEditor.Document;
using AvaCodeEditor.Margins;

Editor.Buffer = new TextBuffer(File.ReadAllText(path));   // the document
Editor.Margins.Add(new LineNumberMargin());               // a gutter of line numbers

Colour is two more lines. The grammar is chosen by file name, the theme by whether your app is light or dark:

using AvaCodeEditor.Highlighting.TextMate;
using TextMateSharp.Grammars;

var languages = new TextMateLanguageRegistry();
Editor.SyntaxTokenizer = languages.FindByFileName(path)?.CreateTokenizer();
Editor.HighlightTheme = languages.LoadTheme(ThemeName.DarkPlus);
Save the file back with Editor.Buffer.GetText() — it returns the document with the line endings it was loaded with, mixed ones included.

How it draws

The editor is a canvas with a fixed grid ruled on it. Every character takes exactly one cell, every cell is the same size, and the gutters you add sit in columns to the left of the text. Because the grid never varies, the editor answers "where is line 40, column 12?" with multiplication, and "what is under this click?" with division — no searching, no per-character objects, nothing that gets slower as the file grows.

414243 444546 a41f3c2 Ada L.9d02b71 Linus T. line numbersblame your marksfolding text area — one glyph, one cell CharWidth — and every x is TextOrigin + column × CharWidth, every y is row × LineHeight
The control: gutter columns you supply, then the text area, ruled into cells.

Two consequences worth knowing before you write anything:

  • A character offset is not a column. A tab is one character but eight cells wide. Ask GridColumns to convert; never assume they are the same number.
  • A screen row is not a document line. Folding hides lines, and a CodeLens adds a row that has no line at all. See Rows are not lines.

Who owns what

The shortest way to learn the API is to learn this split. If a question is about pixels, keys or cells, the editor answers it. If it is about meaning, you do.

The editor doesYou do
Draw text, caret, selectionSay what the text is (ITextBuffer)
Measure and hit-test guttersDraw inside them (IEditorMargin)
Hide folded lines, keep the caret saneSay where regions are (IFoldingProvider)
Draw and click lens rowsSay what they say (ICodeLensProvider)
Reserve the blame column, show tooltipsSupply the blame (ILineAnnotationProvider)
Render and hit-test block buttonsSay what they do (IChangeActionProvider)
Paint bands under the glyphsPick the colours (ILineBackgroundProvider)
Tell you the word, scope and cellDecide what the popover shows
Keep tokens current as you typeSupply the grammar and theme

The document

A document is a list of lines. The editor reads only the lines it is about to draw, so the interface is small on purpose — three interfaces, in fact, stacked by how much power you want to hand over.

ITextBuffer AvaCodeEditor.Document

Solves: letting the editor show a document without owning it — or reading it all. Line access must be O(1), because the renderer asks for the sixty lines on screen every frame and nothing else.

MemberWhat it is
int LineCountHow many lines. Must be cheap — it is asked constantly.
string GetLine(int)One line, without its terminator.
LineTerminator GetLineTerminator(int)Lf, CrLf, Cr or None — kept per line so mixed files round-trip.
int VersionBumped on every change; the editor uses it to know its caches are stale.
event ChangedRaised with a TextChange — which lines went, which arrived.

IEditableTextBuffer adds ReplaceLines (and ReplaceLinesExact, which preserves terminators verbatim). IUndoableTextBuffer adds Undo, Redo and BeginUndoGroup. They are separate so a read-only viewer cannot be written to by accident, and so a host that keeps its own history can refuse to hand undo to the control.

// A viewer that cannot be edited, whatever a key handler tries:
Editor.Buffer = new ReadOnlyLog(path);   // implements ITextBuffer only
Editor.IsReadOnly = true;                // and say so, so navigation still works

TextBuffer AvaCodeEditor.Document

Solves: the ordinary case — a file in memory that can be edited and undone. Use it unless your document is enormous or lives somewhere else.

var buffer = new TextBuffer(File.ReadAllText(path));
Editor.Buffer = buffer;

buffer.ReplaceLines(10, 2, ["one new line"]);   // lines 10–11 become one line
string all = buffer.GetText();                  // original line endings preserved
File.WriteAllText(path, all);
MemberWhat it is
TextBuffer(string text = "")Splits text into lines, remembering each terminator.
string GetText()The whole document back, byte-for-byte in its endings.
LineTerminator PredominantTerminatorWhat new lines get: the most common ending in the file.
void ReplaceLines(int start, int count, IReadOnlyList<string>)The one mutation. Everything else is built on it.
void ReplaceLinesExact(…)The same, with terminators you choose — for undo and for hosts that must preserve endings exactly.
bool Undo() / Redo()One step back or forward. False when there is nothing.
IDisposable BeginUndoGroup(string label)Everything inside the scope becomes one undo step.
int UndoDepth / RedoDepthHow many steps are available — for a host reconciling its own menu.
int UndoLimit / long UndoLineBudgetCaps on history: by step count, and by total snapshotted lines, because one step can hold a whole document.
string? UndoLabel / RedoLabelText for an "Undo rename" menu item.
void ClearHistory()After loading a different file into the same buffer.
events Changed, EditRecorded, UndoStateChangedContent changed; a new edit was recorded (never during undo); undo availability changed.

TextPosition, TextSelection AvaCodeEditor.Document

Solves: saying where something is, unambiguously. TextPosition is a line plus a character offset into that line's text — not a visual column, because a tab is one character and eight cells.

var at = new TextPosition(line: 12, character: 4);
Editor.Selection = new TextSelection(at, at with { Character = 20 });  // anchor … caret
Editor.MoveCaret(at, extend: false);
Editor.BringCaretIntoView();
MemberWhat it is
TextPosition(int Line, int Character)Comparable and ordered, so a < b means "earlier in the document".
TextSelection(TextPosition Anchor, TextPosition Caret)Where the selection started, and where it is being dragged to.
Start / EndThe two ends in document order, whichever way it was dragged.
IsEmptyTrue when it is just a caret.
WithCaret(position)Extend the selection: the anchor stays where the user put it.
The anchor is kept rather than a tidy start/end pair on purpose. Normalising on the way in loses which end the user is dragging, and shift+arrow then jumps.

Undo, and grouping it AvaCodeEditor.Document

Solves: one user action that touches many lines being undone in one press, not fifteen.

using (buffer.BeginUndoGroup("Take left side"))
{
    foreach (var chunk in chunks)
        buffer.ReplaceLines(chunk.Start, chunk.Length, chunk.Replacement);
}   // one step in the history, labelled for the menu

If your application owns a wider history — a merge tool coordinating two documents — turn the control's own handling off and route the keystrokes to your command instead:

Editor.IsUndoEnabled = false;   // Ctrl/Cmd+Z now reaches your host, and one history stays authoritative

A buffer of your own for very large or virtual documents

Solves: documents that do not fit in memory, or do not exist yet — a million-line log, a generated file, a database cursor. Implement ITextBuffer and compose each line when the editor asks for it.

sealed class Generated : ITextBuffer
{
    public int LineCount => 1_000_000;
    public int Version => 1;
    public string GetLine(int i) => $"line {i:N0} — composed just now, stored nowhere";
    public LineTerminator GetLineTerminator(int i) => LineTerminator.Lf;
    public event EventHandler<TextChangedEventArgs>? Changed { add { } remove { } }
}

Nothing else changes: the gutters, the highlighting and the scrollbar all work, because none of them was ever allowed to ask a question about the whole document. The demo's A million lines scenario is exactly this, plus edits held as runs spliced into an index.

A million-line document open, with the status line reading opened in 4 ms
A million lines, opened in 4 ms: the editor read the sixty it had to draw.

The control

CodeEditor is an ordinary Avalonia Control. Everything below is a styled property, so it binds, animates and inherits like any other.

CodeEditor AvaCodeEditor

PropertyWhat it does
ITextBuffer? BufferThe document. Set it and the editor redraws; set a new one and it starts again.
FontFamily / double FontSizeThe typeface. It is measured, never assumed — a proportional fallback still gives a consistent grid.
IBrush? Foreground / BackgroundInk and ground when no theme says otherwise.
int TabSizeCells per tab stop. Default 4.
bool IsReadOnlyBlocks every mutation, keeps navigation, selection and copy. What a viewer pane wants.
bool IsUndoEnabledWhether the control answers Ctrl/Cmd+Z itself. Turn off when your app owns the history.
bool CaretBlinkOff makes rendering deterministic — useful in tests and screenshots.
ILineTokenizer? SyntaxTokenizerThe language. Null means plain text.
IHighlightTheme? HighlightThemeScope → colour. Swapping it repaints without re-tokenizing a line.
IFoldingProvider? FoldingWhere collapsible regions are. Null means nothing folds.
ICodeLensProvider? CodeLensThe rows of information above declarations.
ILineBackgroundProvider? LineBackgroundsA brush behind whole lines — diff bands, the current statement.
ISegmentBackgroundProvider? SegmentBackgroundsBrushes behind runs of characters inside a line.
IList<IEditorMargin> MarginsThe gutter columns, left to right. Add and remove at any time.
event MarginFailedA gutter threw and was skipped. Raised once per fault, not once per frame.
double TextOriginWhere the text starts: the total width of the gutters.

Caret and selection

Solves: driving the editor from your own commands — Go To Line, Find, "jump to this stack frame" — without knowing anything about pixels.

Editor.MoveCaret(new TextPosition(120, 0), extend: false);
Editor.BringCaretIntoView();                       // scrolls the least it can

Editor.Selection = new TextSelection(start, end);  // clamped onto real character boundaries
string picked = Editor.SelectedText;               // "" when it is only a caret
Editor.SelectionChanged += (_, _) => status.Text = $"{Editor.CaretPosition}";
MemberWhat it does
TextSelection SelectionGet or set. Setting clamps into the document and onto cluster boundaries — a caret is never left inside an emoji.
TextPosition CaretPositionThe moving end: where typing goes.
string SelectedTextThe selection as text; with several carets, every one of them, top to bottom.
void MoveCaret(TextPosition, bool extend)Move, extending the selection instead of collapsing it when asked.
void SelectAll()What it says.
void BringCaretIntoView()Scrolls the minimum that puts the caret's cell fully on screen.
TextPosition PositionAt(Point)The document position under a point in control coordinates.
Rect CaretRect / GetCellRect(TextPosition)Where the caret is; where any cell is — for your own overlays, squiggles and popovers.
event SelectionChangedRaised whenever the caret or the selected region changes.

Editing from code

Solves: your commands making the same edits the keyboard makes — with the same undo behaviour and the same caret rules.

if (Editor.CanEdit)                 // false when read-only, or the buffer is not editable
{
    Editor.InsertText("TODO: ");    // replaces the selection, like typing does
    Editor.Backspace(wholeWord: true);
    Editor.Undo();
}
Editor.Copy(); Editor.Cut(); Editor.Paste();

For edits addressed by position rather than by caret, the same rules live in two static classes you can call directly — both are pure functions over a buffer, which is why they are testable without a window:

HelperWhat it is for
DocumentEditor.Replace / Insert / DeleteCharacter-level edits on a line-based buffer; returns where the caret ends up.
DocumentEditor.GetText(buffer, start, end)Text between two positions, carrying the document's own line endings.
CaretNavigation.Left / Right / Vertical / WordLeft / WordRightWhere a key would take the caret. Grapheme clusters, not chars.
CaretNavigation.LineStartSmart Home: first non-whitespace, then column 0.
CaretNavigation.WordAt(text, offset)The word a double-click would select.
GridColumns.VisualColumn / CharacterAtColumn / WidthCharacter offset ⇄ grid column, with tabs expanded. The conversion you must not do by hand.

Keyboard and mouse

Solves: people already knowing how to use it. The bindings are the ones every code editor has, and they follow the platform: Cmd is the command key on macOS, Ctrl everywhere else.

GestureDoes
ArrowsMove by one cluster or line. With Shift, extend the selection.
Ctrl+arrow · Alt+arrow (macOS)Move by word.
Home / EndSmart Home (first non-whitespace, then column 0) and end of line.
Ctrl+Home / Ctrl+End · Cmd+↑ / Cmd+↓Start and end of the document.
Cmd+← / Cmd+→ (macOS)Start and end of the line.
PageUp / PageDownBy one screen of rows — over a folded region in one step.
Shift+Alt+arrow · Shift+Alt+Cmd+arrow (macOS)Open a rectangular selection from the keyboard.
EscClose the popover; else drop the extra carets; else collapse the selection.
Backspace / DeleteThe selection, else one cluster. With the word modifier, the whole word.
TabInserts a tab — it is a character in a code document, not focus movement.
Ctrl/Cmd+A · C · X · VSelect all, copy, cut, paste.
Ctrl/Cmd+Z · Shift+Ctrl/Cmd+Z · Ctrl+YUndo and redo (Ctrl+Y off macOS), unless IsUndoEnabled is false.
Menu key · Shift+F10Ask for a popover at the caret.
Click · double · tripleCaret · select the word · select the line. Drag continues in that unit.
Alt+dragA rectangle, and a caret on every line of it.
Ctrl/Cmd+Alt+clickAdd one more caret, keeping the ones already there.
Right-clickRaises PopoverRequested for whatever is under the pointer.

Metrics and scrolling

Solves: anything you draw beside the editor needing the same geometry it uses — a minimap, a scrollbar of your own, an overlay.

MemberWhat it is
EditorMetrics MetricsThe measured cell: CharWidth, LineHeight, Baseline, plus XForColumn, YForLine, ColumnAtX, LineAtY.
EditorViewport TextViewportThe slice on screen: first visible line, how many, horizontal offset.
Size Extent / Vector Offset / Size ViewportStandard ILogicalScrollable: put the control in a ScrollViewer and it scrolls by lines.
double TextOriginTotal gutter width — where cell 0 begins.
Extent's width is what has been seen, not what exists. Finding the widest line of a file means reading every line of it, and an editor that did that on open would not open a million-line file at all. The width grows as lines come on screen.

Gutters

A gutter — the editor calls it a margin — is a vertical strip beside the text. Line numbers are one. So are folding chevrons, blame, breakpoints, and the ≫ buttons of a merge tool. The editor owns none of them: it lays them out left to right, hands each one a rectangle and the facts about the frame, and asks it to draw.

Margins scroll vertically with the text and stay pinned horizontally — a line number that slides off the left edge on a long line is worse than no line number at all.

A bookmark column of violet dots to the left of the line numbers
Two gutters: a bookmark column of forty lines, then the line numbers.

IEditorMargin AvaCodeEditor.Margins

Solves: adding a column to the editor without changing the editor. You implement three things — how wide, what to draw, what a click means — and the editor does the rest.

MemberWhat you write
double GetWidth(EditorMetrics, ITextBuffer?)How wide the column is. Arithmetic, not measurement — this runs on every layout, and a width that follows what is on screen slides the code sideways as you scroll.
void Render(DrawingContext, in MarginContext)Draw. Runs every frame for every visible row, so it must be a lookup, never a search.
bool OnPointerPressed(Point, in MarginContext)True if you consumed it — which also makes you the owner of the whole gesture: every move and the release come to you, wherever they land.
void OnPointerMoved(Point, in MarginContext)Optional. Hover, or a drag you own.
bool OnPointerReleased(Point, in MarginContext)Optional. A destructive button acts here, and only if the release landed on the same thing the press did — pressing and sliding off is a cancel, in every toolkit there is.
void OnPointerExited()Drop hover and any held press: no release is coming.
string? GetTooltip(Point, in MarginContext)Text for what is under the pointer. The editor shows it on itself.
event Changed"My content or width changed." May be raised from any thread; the editor marshals.

MarginContext AvaCodeEditor.Margins

Solves: a margin needing the whole truth about this frame in one value — the document, the viewport, the theme, the grid — instead of reaching back into the control.

MemberWhat it gives you
Buffer, Metrics, Viewport, BoundsThe document, the cell size, the visible slice, your own rectangle.
Foreground, IsDarkThe host's ink, and which way the theme is pointing.
CaretWhere the caret is — for emphasising the line it is on.
double YOf(int line)The top of a line, in your coordinates. Far off-screen for a line inside a fold, so a naive loop under-draws instead of stacking glyphs.
int LineAt(double y)The line under a y, or −1. A click beside a lens row belongs to the line it describes.
VisibleLinesIterate this, not a numeric range: it steps over what a collapsed region hides, and allocates nothing.
bool IsHidden(int line)True when the line is folded away.
GutterText(double scale)Font size, per-cell advance and baseline for drawing text a notch smaller than the code, on the code's own grid.

The four built in

Solves: the gutters everyone needs, so you only write the ones nobody else has.

Editor.Margins.Add(new FoldingMargin { View = Editor.FoldingState });
Editor.Margins.Add(new LineNumberMargin { FirstLineNumber = 1 });
Editor.Margins.Add(new LineAnnotationMargin { Provider = blame });
Editor.Margins.Add(new ChangeActionMargin { Provider = merge });
MarginShowsWorth knowing
LineNumberMarginLine numbers, the caret's line brighterSized from the digit count, so it never jitters as you scroll. SelectsLineOnClick, FirstLineNumber, LineClicked.
FoldingMargin▾ / ▸ beside foldable linesSet View = Editor.FoldingState. Acts on the press: folding is instantly reversible, so the cancel rule a rewrite button needs would only make it feel slow.
LineAnnotationMarginBlame, or any per-line labelWidth is declared by the provider, not measured, so text never shifts sideways. Runs of like lines are labelled once; CollapseRepeats turns that off. Empty provider ⇒ zero width.
ChangeActionMarginButtons anchored to blocks (≫ / ≪)Acts on release, and only where the press began. ButtonFace, ButtonHover, ButtonPressed, ButtonForeground if the derived colours are not yours.

Write your own

Solves: the column only your application knows about — bookmarks, coverage, lint counts, a merge decision. Here is a whole gutter: it reserves a width, draws a dot on the lines in a set, and toggles one on a click.

sealed class BookmarkMargin : IEditorMargin
{
    private readonly HashSet<int> _lines = [];

    public event EventHandler? Changed;

    // Arithmetic over the grid — never a measurement.
    public double GetWidth(EditorMetrics metrics, ITextBuffer? buffer) =>
        Math.Max(12, metrics.CharWidth * 1.6);

    public void Render(DrawingContext drawing, in MarginContext context)
    {
        var brush = new SolidColorBrush(context.IsDark ? Colors.MediumPurple : Colors.RebeccaPurple);
        double radius = Math.Min(4, context.Metrics.LineHeight / 4);
        double centre = context.Bounds.Width / 2;

        // Only the rows on screen, and only the marked ones: the set is the state,
        // the frame is a reading of it.
        foreach (int line in context.VisibleLines)
        {
            if (!_lines.Contains(line))
                continue;

            double y = context.YOf(line) + (context.Metrics.LineHeight / 2);
            drawing.DrawEllipse(brush, null, new Point(centre, y), radius, radius);
        }
    }

    public bool OnPointerPressed(Point position, in MarginContext context)
    {
        int line = context.LineAt(position.Y);
        if (line < 0)
            return false;

        if (!_lines.Remove(line))
            _lines.Add(line);

        Changed?.Invoke(this, EventArgs.Empty);
        return true;
    }

    public string? GetTooltip(Point position, in MarginContext context) =>
        _lines.Contains(context.LineAt(position.Y)) ? "Bookmarked" : null;
}

Then Editor.Margins.Add(new BookmarkMargin()); and it is a column of the editor. Two rules to keep:

  • Consume a press only when you did something with it. A margin that returns true and does nothing is a dead strip that swallows clicks — and, because consuming a press makes you the owner of the gesture, drags as well.
  • Marks are line numbers, and lines move. Subscribe to the buffer's Changed and shift your set by the TextChange, or your bookmarks will drift as the document is edited.

When one throws MarginFailedEventArgs

Solves: a bug in your gutter not costing somebody their unsaved document. A margin runs inside the render pass; the editor catches it, drops that column for the frame, and tells you once — not sixty times a second.

Editor.MarginFailed += (_, e) =>
{
    status.Text = $"The {e.Margin.GetType().Name} column stopped: {e.Error.Message}";
    Editor.Margins.Remove(e.Margin);   // safe here: the event arrives after the frame, not inside it
};

Nothing in the editor is broken when this fires. The caret, the text and the document are exactly as they were.

ILineAnnotationProvider, LineAnnotation AvaCodeEditor.Annotations

Solves: a blame column — per-line metadata that arrives late, from somewhere slow, and must not push the code sideways when it does.

You declare how wide the column may be in characters; the editor reserves that and never re-measures. Return null while the data is still loading, then raise AnnotationsChanged when it lands — from any thread; the editor marshals.

sealed class Blame : ILineAnnotationProvider
{
    public event EventHandler? AnnotationsChanged;

    public int MaxCharacters => 30;                   // declared, not measured

    public LineAnnotation? GetAnnotation(int line)
    {
        if (!_loaded)
            return null;                              // nothing yet, and the column takes no width

        var commit = _commits[line];
        return new LineAnnotation($"{commit.Short} {commit.Author}", Tooltip: commit.Message);
    }

    public void Arrived() { _loaded = true; AnnotationsChanged?.Invoke(this, EventArgs.Empty); }
}

Editor.Margins.Add(new LineAnnotationMargin { Provider = new Blame() });

Consecutive lines whose annotation text is equal are labelled once, at the top of the run — otherwise one commit repeats down two hundred identical rows. Every line still answers with the full text in its tooltip, including the ones with no label drawn.

A blame column beside the code, one label per run of lines from the same commit
One label per run of like lines; hovering any row shows the whole of it.

IChangeActionProvider, ChangeAction AvaCodeEditor.Actions

Solves: buttons that belong to a block of lines and rewrite it — the ≫ / ≪ of a merge tool, "apply this fix", "accept this suggestion".

sealed class Merge : IChangeActionProvider
{
    public event EventHandler? Changed;

    public int MaxActionsPerLine => 1;

    public IReadOnlyList<ChangeAction> GetActions(int line) =>
        _chunkByLine.TryGetValue(line, out var chunk)
            ? [new ChangeAction($"take-left-{chunk.Id}", "≫", "Take the left side", () => Take(chunk))]
            : [];
}

Editor.Margins.Add(new ChangeActionMargin { Provider = new Merge() });
MemberWhat it is
ChangeAction(Id, Glyph, Tooltip, Execute)The Id names the action and the thing it acts on — "take-left-chunk-7", not "take-left" — because the button is drawn once for a whole block of lines and the editor tells them apart by id.
GetActions(line)What is anchored at this line. Return the same action object for every line of the block.
MaxActionsPerLineHow many the column must have room for. Reserved width, again, rather than measured.
ExecuteRuns on the UI thread, on release, and only if the release landed on the same button as the press. The editor neither knows nor cares what it does.
Buttons in a gutter column anchored beside three loops
The buttons live in a column of their own — not inside the text, where a caret would have to fight them for the same clicks.

Structure

Two features share one idea: what is on screen is not the document. Folding takes rows away; a CodeLens adds one. The editor keeps the map between them, so your gutters, the caret and every click stay correct without knowing either feature exists.

IFoldingProvider, FoldRegion AvaCodeEditor.Folding

Solves: collapsing a block without the editor knowing what a block is. It asks you one question, about one line, and only for lines it is drawing — so a million-line file costs nothing.

sealed class BraceFolding : IFoldingProvider
{
    // Built once from the document, and again when it changes — never per frame.
    private readonly Dictionary<int, FoldRegion> _regions = ScanBraces(buffer);

    public event EventHandler? Changed;

    // "Does a region start on this line?" — nothing else is ever asked.
    public FoldRegion? GetRegion(int line) =>
        _regions.TryGetValue(line, out var region) ? region : null;
}

Editor.Folding = new BraceFolding(buffer);
Editor.Margins.Add(new FoldingMargin { View = Editor.FoldingState });
MemberWhat it is
FoldRegion(int Header, int Last, string? Placeholder)The header line stays visible; everything after it up to Last hides. The placeholder is what is drawn after the header's text — "⋯ 12 lines ⋯" if you say nothing.
bool HidesAnythingFalse for a one-liner. Such a region is ignored.
FoldRegion? GetRegion(int line)Your answer. Guarded by the editor: if it throws, that line loses its chevron — never the frame.
event ChangedRe-parsed, so the chevrons should be re-asked.

The editor remembers which regions are closed — that survives a re-parse, and is nothing a provider should have to store:

On CodeEditorDoes
Collapse(header) / Expand(header) / ToggleFold(header)Close, open, or flip the region starting on a line.
ExpandAll()Open everything.
IsCollapsed(header)Is it closed?
CollapsedRegionsEverything currently closed — save it, restore it next session.
RegionAt(line)What the provider says about a line, guarded.
IFoldingView FoldingStateFolding as a gutter sees it. Hand it to FoldingMargin, or drive folding from your own menu.

ICodeLensProvider, CodeLensItem AvaCodeEditor.Lenses

Solves: putting a clickable line of information above a declaration — "15 references · Bo, 6 days ago · 2 tests passing" — without the editor learning what a reference is.

You give two things: which lines carry a lens, and what each one says. The anchors are a list you hand over, precisely because the editor may not ask a million lines whether they have a lens.

sealed class Lenses : ICodeLensProvider
{
    public event EventHandler? Changed;

    public IReadOnlyList<int> AnchorLines => _declarations;      // the lines with a row above them

    public IReadOnlyList<CodeLensItem>? GetLens(int line) =>
    [
        new CodeLensItem("15 references", "Everything that calls this", () => ShowResults(line)),
        new CodeLensItem("Bo, 6 days ago", "Last commit that touched it"),   // no action: a label
    ];
}

Editor.CodeLens = new Lenses();

A lens row occupies exactly one row of the character grid, so nothing about the geometry changes. Items are clicked on release, and only where the press began.

Folded regions with placeholders, and CodeLens rows above each declaration
Both at once: regions folded into their first line, lens rows above declarations.

Rows are not lines AvaCodeEditor.Rendering.LineLayout

Solves: the bug every editor with folding has had — a gutter that draws line n at row n, and paints fifty glyphs on top of a folded header. Everything that turns a y into a line, or a line into a y, goes through one map.

document lines — still there, still edited screen rows — what is drawn 1 using System.Text.Json; 2 3 public class Quoter 4 { 5 var tax = … 6 return … 7 } 8 9 // end row 0 line 1 row 1 line 2 row 2 CodeLens — no line of its own row 3 line 3 ⋯ 4 lines ⋯ } row 4 line 8 row 5 line 9 a lens is inserted above its line lines 4–7 are hidden, not deleted
One region collapsed, one lens added: nine lines become six rows.

You rarely touch LineLayout directly — MarginContext already speaks in it. It matters when you write a gutter: iterate context.VisibleLines and use context.YOf(line), and folding costs you nothing. With nothing folded and no lens the map is the identity, row n is line n, and it costs one object.

MemberWhat it answers
RowAt(row)What this row shows: RowKind.Text plus a line, or RowKind.Lens.
RowOfLine(line)Which row draws a line. A hidden line answers with its header's row — the one place on screen that stands for it.
LensRowOfLine(line)The lens row above a line, or −1.
IsHidden(line)Inside a collapsed region.
LineByRowOffset(line, rows)What an arrow key means: a collapsed region is one step, not the fifty lines it hides.
FirstVisibleAtOrAfter(line)Where a caret goes when it lands inside a fold.

Colour behind text

Two providers paint under the glyphs. One colours whole lines, the other colours runs of characters inside a line. Both are asked per visible line, on every frame, so both must be lookups into something you prepared earlier.

ILineBackgroundProvider AvaCodeEditor.Decorations

Solves: the band behind a line — added, removed and conflicting lines in a diff; the statement a debugger is about to run; the row a search landed on.

sealed class Bands : ILineBackgroundProvider
{
    public event EventHandler? Changed;

    public IBrush? GetLineBackground(int line) =>
        _added.Contains(line)   ? Green
      : _removed.Contains(line) ? Red
      : null;                                  // null means "no band"
}

Editor.LineBackgrounds = new Bands();

ISegmentBackgroundProvider, TextSegment AvaCodeEditor.Decorations

Solves: saying which part of a changed line changed — word level diff, every occurrence of a search term, a spelling range.

public IReadOnlyList<TextSegment> GetLineSegments(int line) => _hits[line];

// Start and Length are CHARACTER offsets, never visual columns — the editor owns the grid
// and does the conversion, because a tab is one character and several cells.
new TextSegment(Start: 12, Length: 5, Brush: highlight)

Set both and you get both: the band paints first, the runs paint over it.

Coloured bands behind changed lines, with every occurrence of a word highlighted inside them
Bands mark the changed lines; the segments mark what changed in them.

Many carets

Alt+drag a rectangle over a column of numbers and every line of it gets a caret. What you type next is typed at all of them, as one undo step, and copying gives you the column rather than a smear of whole lines.

A rectangular selection across seven lines of a table, one caret per line
Seven carets: type once, and it happens seven times.

ColumnSelection AvaCodeEditor

Solves: a rectangle being a rectangle. A box is a run of visual columns over a run of lines — so it can extend past the end of a short line, which an ordinary selection cannot. Each line's own character offsets are worked out from its own text when the box becomes carets.

// Exactly what Alt+drag does, from code: lines 4–10, cells 29–36.
Editor.SetBoxSelection(new ColumnSelection(3, 29, 9, 36));

Editor.AddCaret(new TextPosition(12, 4));   // one more, keeping the rest — Ctrl/Cmd+Alt+click
Editor.CollapseToPrimaryCaret();            // back to one — Escape
MemberWhat it is
ColumnSelection(AnchorLine, AnchorColumn, CaretLine, CaretColumn)The box, with FirstLine, LastLine, LeftColumn, RightColumn for the tidy version.
ColumnSelection? BoxSelectionThe rectangle in force, or null.
IReadOnlyList<TextSelection> AdditionalSelectionsEvery caret past the first, in document order.
bool HasMultipleCaretsTrue while there is more than one.
bool AddCaret(TextPosition)Add one. False if a caret is already there.
bool CollapseToPrimaryCaret()Back to one caret. True if there was more than one.

Popovers

Right-click, and the editor tells you everything it knows about that spot, then shows whatever control you hand back — anchored to the character cell under the pointer. It never decides what a menu should say, because that is a question about your application.

PopoverRequested AvaCodeEditor

Solves: a context menu that knows where it is — without you re-deriving the word, the selection or the syntax under the pointer, and without the control learning what a symbol is.

Editor.PopoverRequested += (_, e) =>
{
    // e.Scopes is the TextMate scope chain: "am I in a string, a comment, a type name?"
    bool inComment = e.Scopes.Any(s => s.Contains("comment"));

    e.Content = inComment
        ? BuildCommentMenu(e.LineText)
        : BuildSymbolMenu(e.Word, e.Position);     // shown anchored to that cell
};
MemberWhat it gives you
TextPosition PositionThe document position under the gesture.
string Word / string LineTextThe word under it (or empty), and the whole line.
TextSelection SelectionThe selection as it stands — a right-click inside one leaves it alone.
IReadOnlyList<string> ScopesThe TextMate scopes, outermost first. This is what makes a menu context-dependent with no language knowledge in the control.
Rect AnchorThe cell's rectangle, in control coordinates.
PopoverTrigger TriggerPointer, keyboard, or your own call.
Control? ContentSet this and the editor shows it. Leave it null and nothing happens.
bool HandledSet it to stop the editor here — the door for a host that would rather show its own window.
Also on CodeEditorDoes
ShowPopover(Control, TextPosition)Show one yourself — from a menu command, or after a lookup returns.
RequestPopover(TextPosition, PopoverTrigger)Raise the event for a position and show what comes back.
HidePopover() / IsPopoverOpenClose it; ask whether one is open.
GetCellRect(TextPosition)Where a cell is — for a tooltip, a squiggle or an overlay of your own. Accounts for gutters, scrolling, tabs and folds.
A popover anchored to a word, offering actions based on the syntax scope under the pointer
The panel is the host's; the position, the word and the scopes are the editor's.

Highlighting

Colour arrives in two halves that never meet. A tokenizer says what each run of characters is — a keyword, a string, a comment — in the vocabulary of TextMate scopes. A theme says what that vocabulary looks like. Tokens carry scopes and never colours, which is why switching themes repaints instantly and re-reads nothing.

one line of text var tax = 0.2m; ILineTokenizer TextMate grammar, line by line HighlightToken start 0, length 3 ["source.cs", "keyword.other"] IHighlightTheme scope chain → colour + style one glyph run per style drawn on the grid a theme swap re-runs only this arrow
Text → scopes → style → ink. Only the last step depends on the theme.

TextMateLanguageRegistry AvaCodeEditor.Highlighting.TextMate

Solves: picking a language for a file, from grammars that ship with the package, grammars a user dropped in a folder, and anything you register yourself.

var languages = new TextMateLanguageRegistry();

Editor.SyntaxTokenizer = languages.FindByFileName("pricing.ts")?.CreateTokenizer();
Editor.HighlightTheme  = languages.LoadTheme(ThemeName.DarkPlus);

// Somebody's own grammars, from a folder they can open in a file manager:
languages.LoadFromFolder(userGrammarFolder);
foreach (var failure in languages.LoadFailures)
    log.Warn($"{failure.Source}: {failure.Message}");   // a typo in a grammar must be findable
MemberWhat it does
FindByFileName(name)The language for a file name, by extension.
FindById(id)By grammar scope (source.cs) or by VS Code language id (csharp). Round-trips, so a per-file choice can be saved and restored.
Register(ILanguageDefinition)Add one in code.
LoadFromFolder(folder)Load every grammar in a folder. A broken file is skipped, never fatal.
LoadFailuresWhy one did not load. Degrading to plain text is right; doing it silently is not.
LanguagesEverything known — for a "language" menu.
LoadTheme(ThemeName)A bundled VS Code theme, ready to resolve scopes.

Your own language

Solves: supporting a language nobody has heard of — a config format, a DSL, your query syntax — without a new build of anything.

The easy way: put a .tmLanguage.json grammar in a folder and call LoadFromFolder. That is the whole feature: TextMate grammars are the de-facto standard, so one probably already exists for what you need, and users can add their own without you shipping anything.

The other way: if your language is easier to tokenize in code than in a grammar, implement ILineTokenizer yourself and hand it over. The editor cannot tell the difference.

sealed class TodoTokenizer : ILineTokenizer
{
    private static readonly string[] Comment = ["source.todo", "comment.line"];

    public ITokenizerState InitialState => PlainState.Instance;

    public LineTokenization TokenizeLine(string line, ITokenizerState previous, TimeSpan timeout)
    {
        var tokens = line.StartsWith('#')
            ? new[] { new HighlightToken(0, line.Length, Comment) }
            : [];

        return new LineTokenization(tokens, PlainState.Instance);
    }
}

Themes AvaCodeEditor.Highlighting.Theming

Solves: light and dark, and everybody's opinion about colour. A theme is data — a VS Code JSON theme — so users can bring their own.

MemberWhat it is
IHighlightTheme.Resolve(scopes)A scope chain in, a TokenStyle out. Implementations memoize — this runs for every token of every visible line.
Name, IsDarkWhat to show in a menu, and which way it leans.
EditorBackground, DefaultForegroundThe ground and the ink for text no rule matched.
TokenStyle(uint Foreground, FontStyleFlags)Colour as 0xAARRGGBB — the highlighting package deliberately knows nothing about Avalonia — plus bold/italic/underline.
TextMateHighlightThemeThe bundled implementation over a VS Code theme file.

The control also follows your application's appearance, and a host that disagrees wins: set AvaCodeEditor.SelectionBrush, AvaCodeEditor.CaretBrush or the standard TextControlForeground on any parent, and Avalonia's resource lookup does the rest.

The same document twice: once in the app's theme, once with the host's own selection and caret colours
Same control, same document: the app's palette on the left, a host's own keys on the right.

Tokenizers AvaCodeEditor.Highlighting.Tokenization

Solves: colouring a document while it is being typed into, without re-reading it. A line is tokenized with the state the previous line ended in; if a re-tokenized line ends in the same state as before, everything below it is still correct and the work stops there.

TypeWhat it is
ILineTokenizerInitialState, TokenizeLine(line, previousState, timeout), and an optional Warmup() that compiles a grammar off the render path.
ITokenizerStateOpaque "where the grammar was at the end of this line" — e.g. inside a block comment. Must have value equality: that equality is what stops re-tokenization.
LineTokenization(tokens, endState)The result. TimedOut says these are a fallback, not the line's real answer — do not cache it as final.
HighlightToken(Start, Length, Scopes)One run inside one line, in character offsets, carrying the scope chain.
ITextSourceLines by index — a tiny interface of its own, so highlighting never depends on the control.

DocumentHighlighter AvaCodeEditor.Highlighting.Tokenization

Solves: keeping a whole document's tokens current, incrementally and on a budget. The editor uses one internally; you need it only if you are highlighting outside the control — a minimap, a printout, a search preview.

var highlighter = new DocumentHighlighter(source, tokenizer) { MaxTokenizedLineLength = 20_000 };
highlighter.Warmup();                                  // compile the grammar off the render path
highlighter.Tokenize(throughLine: 200, TimeSpan.FromMilliseconds(4));
IReadOnlyList<HighlightToken>? tokens = highlighter.GetTokens(120);   // null: not reached yet
MemberWhat it does
Tokenize(throughLine, budget)Bring lines up to date, spending at most budget. True when the range is current.
GetTokens(line)Tokens, or null if that line has not been reached. Treat spans as advisory and clamp them — a line can be shorter now than when it was tokenized.
ApplyChange(start, removed, added)Mirror an edit, so the cache follows the lines that moved.
HasPendingWork, DirtyFromIs there work left, and from which line.
Reset()New document, or new grammar.
MaxTokenizedLineLengthLines longer than this are left plain — a minified bundle must not stall the editor on one line.

Recipes

Four applications, built out of the pieces above. None of them needed a feature in the control: each is a different set of providers plugged into the same editor.

A merge tool

Wants: two panes side by side, coloured by what differs, with buttons that push a change from one side to the other — and one undo history across both.

foreach (var pane in new[] { left, right })
{
    pane.LineBackgrounds    = new DiffBands(model, pane.Side);    // added / removed / conflict
    pane.SegmentBackgrounds = new WordDiff(model, pane.Side);     // what changed inside a line
    pane.Margins.Add(new LineNumberMargin());
    pane.Margins.Add(new ChangeActionMargin { Provider = new Push(model, pane.Side) });

    // The application owns the history, so both panes undo as one document.
    pane.IsUndoEnabled = false;
}

// Scroll them together: Offset is an ordinary property.
left.PropertyChanged += (_, e) => { if (e.Property.Name == "Offset") right.Offset = left.Offset; };

The editor still knows nothing about diffs. It is drawing brushes it was handed, and invoking callbacks it was given.

A debugger

Wants: red dots you can click, a yellow band on the statement about to run, and the file staying editable when the program is not.

editor.Margins.Add(new BreakpointMargin(session));   // your own — a set of lines, a red dot, a click
editor.Margins.Add(new LineNumberMargin());
editor.LineBackgrounds = new CurrentStatement(session);

session.Stopped += (_, at) =>
{
    editor.MoveCaret(new TextPosition(at.Line, 0), extend: false);
    editor.BringCaretIntoView();
    editor.IsReadOnly = true;         // frozen while stopped; editable again on continue
};
A breakpoint dot in the gutter and the current statement banded across the line
A gutter of your own, and one line background provider. That is the whole debugger view.

A log viewer

Wants: a two-gigabyte file, opened now, coloured by severity, and impossible to type into.

editor.Buffer = new LogFile(path);        // ITextBuffer only — GetLine reads from an index
editor.IsReadOnly = true;                 // navigation, selection and copy still work
editor.LineBackgrounds = new Severity();  // warn amber, error red
editor.Margins.Add(new LineNumberMargin());

Because ITextBuffer has no editing methods at all, this file cannot be changed by a stray key handler — the type says so, not a flag.

A blame view

Wants: who last touched each line, fetched in the background, appearing without moving anything.

var blame = new Blame();
editor.Margins.Add(new LineAnnotationMargin { Provider = blame });

_ = Task.Run(async () =>
{
    var commits = await git.BlameAsync(path);   // slow, off the UI thread
    blame.Publish(commits);                     // raises AnnotationsChanged; the editor marshals
});

Until it lands the column is empty and takes no width — so the code does not shift sideways when the blame arrives, and does not sit behind an empty strip while it loads.

Every type at a glance

TypeNamespaceSolves
CodeEditorAvaCodeEditorThe control itself.
ColumnSelectionAvaCodeEditorA rectangular selection, in grid cells.
PopoverRequestedEventArgsAvaCodeEditorEverything under a right-click: position, word, selection, scopes, cell.
PopoverTriggerAvaCodeEditorWhat asked for the popover.
ITextBuffer…DocumentA document the editor can show without owning it.
IEditableTextBuffer…Document…and can change.
IUndoableTextBuffer…Document…and can undo.
TextBuffer…DocumentThe in-memory document, with history and exact line endings.
TextLine, LineTerminator…DocumentA line with its exact ending — the unit undo restores.
TextChange, TextChangedEventArgs…DocumentWhich lines went, which arrived.
TextPosition, TextSelection…DocumentWhere something is; what is selected.
CaretNavigation…EditingWhere a key would put the caret — as pure functions.
DocumentEditor…EditingCharacter-level edits over a line-based buffer.
EditorMetrics, EditorViewport…RenderingThe cell, and the slice on screen.
GridColumns…RenderingCharacter offset ⇄ visual column, tabs and clusters included.
LineLayout, EditorRow, RowKind…RenderingThe map between screen rows and document lines.
TypefaceMetrics, TokenBrushes, RunStyle…RenderingMeasuring the grid; turning theme colours into shared brushes.
IEditorMargin, MarginContext…MarginsA gutter column, and everything it needs for a frame.
LineNumberMargin…MarginsLine numbers that never jitter.
FoldingMargin…MarginsThe chevron column.
LineAnnotationMargin…MarginsThe blame column.
ChangeActionMargin…MarginsThe button column.
MarginFailedEventArgs, MarginBrushes…MarginsA gutter that threw; the editor's ink at an opacity.
IFoldingProvider, FoldRegion, IFoldingView…FoldingWhere regions are, and what is closed.
ICodeLensProvider, CodeLensItem…LensesThe row above a declaration.
ILineAnnotationProvider, LineAnnotation…AnnotationsPer-line metadata, arriving late.
IChangeActionProvider, ChangeAction…ActionsButtons anchored to a block of lines.
ILineBackgroundProvider…DecorationsA brush behind a whole line.
ISegmentBackgroundProvider, TextSegment…DecorationsBrushes behind runs inside a line.
ILanguageDefinition, ILanguageRegistry…HighlightingA language, and the open set of them.
TextMateLanguageRegistry, TextMateLanguage…Highlighting.TextMateBundled grammars, plus any dropped in a folder.
TextMateTokenizer, TextMateHighlightTheme…Highlighting.TextMateGrammar and VS Code theme, wired up.
LanguageLoadFailure…Highlighting.TextMateWhy a grammar did not load — reported, not guessed at.
IHighlightTheme, TokenStyle, FontStyleFlags…Highlighting.ThemingScope chain → colour and emphasis.
ILineTokenizer, ITokenizerState, LineTokenization…Highlighting.TokenizationIncremental, line-by-line tokenization.
HighlightToken, ITextSource…Highlighting.TokenizationOne coloured run; the document as the highlighter sees it.
DocumentHighlighter…Highlighting.TokenizationKeeping a document's tokens current, on a budget.

Packages and versions

PackageContains
AvaCodeEditorThe control, the document model, the gutters, folding, CodeLens, multi-caret, popovers.
AvaCodeEditor.HighlightingTokenization, the language registry, themes, and the TextMate implementation. UI-framework independent.

The major version tracks Avalonia's. AvaCodeEditor 12.x.y is built for Avalonia 12 on .NET 10; when Avalonia 13 arrives, so does AvaCodeEditor 13.

The control is closed source and free of charge — the licence travels inside the package. The demo application is published in full: every screenshot on this page is one of its twelve scenarios, and each is a small file you can read.

Run the demo in your browser · Back to the front page