Editor fundamentals
This guide covers the data model shared by every editor host. Import it with:
import 'package:artisanal/editor_core.dart';
Documents
TextDocument stores text as lines and graphemes while exposing both offset
and line/column access:
final document = TextDocument(text: 'A👩💻\nsecond');
print(document.lineCount); // 2
print(document.length); // graphemes, including the newline
print(document.lineAt(0)); // A👩💻
final offset = document.offsetForPosition(
const TextPosition(line: 1, column: 2),
);
final position = document.positionForOffset(offset);
Use these APIs rather than Dart String indexing when editor coordinates are
involved:
graphemeAt(offset)lineAt(line)andlineGraphemesAt(line)lineLength(line)lineStartOffset(line)andlineEndOffset(line)textInRange(startOffset:, endOffset:)graphemesInRange(startOffset:, endOffset:)offsetForPosition(position)andpositionForOffset(offset)
TextDocument always has at least one logical line. Its revision increases
when content changes.
Copy and mutate
copy() creates an independent document that initially shares immutable
storage:
final before = TextDocument(text: 'hello');
final after = before.copy();
final change = after.replaceTextRange(
startOffset: 5,
endOffset: 5,
replacement: ' world',
);
assert(before.text == 'hello');
assert(after.text == 'hello world');
assert(change.insertedLength == 6);
Prefer targeted mutations when you know the changed range:
replaceTextRangeaccepts aString.replaceOffsetRangeaccepts already-split graphemes.replaceLineTextRangereplaces a logical line window.replaceText,replaceLines, andreplaceLineTextsreplace all content.
Targeted methods return TextDocumentChange, which language services and
anchored data can use for incremental updates.
Changes
TextDocumentChange describes one replacement in both coordinate systems:
final change = document.replaceTextRange(
startOffset: 0,
endOffset: 1,
replacement: 'Header',
);
print(change.startOffset);
print(change.oldEndOffset);
print(change.newEndOffset);
print(change.startPosition.line);
print(change.newEndPosition.column);
When a host receives complete before/after text rather than a known edit, use
computeTextDocumentChange or computeTextDocumentChangeForDocuments.
Cursor and single selection state
EditorState stores a line/column cursor and one directional selection:
final state = EditorState(line: 0, column: 0);
state.moveCursorTo(const TextPosition(line: 1, column: 2));
state.beginSelection();
state.extendSelectionTo(const TextPosition(line: 1, column: 5));
print(state.selection?.start.column); // normalized start
print(state.selection?.end.column); // normalized end
print(state.selection?.base.column); // original anchor
print(state.selection?.extent.column); // moving edge
The direction matters for keyboard selection. start and end normalize the
range, while base and extent preserve its direction.
Offset and line snapshots
Pure editing functions use immutable snapshots:
TextOffsetStateSnapshotuses document offsets.TextLineStateSnapshotusesTextPositions.
final collapsed = TextOffsetStateSnapshot.collapsed(cursorOffset: 4);
final selected = TextOffsetStateSnapshot.selection(
baseOffset: 2,
extentOffset: 8,
);
These values make it practical to run editing rules in tests, servers, or
custom hosts without constructing TextAreaModel.
Multiple selections
TextSelectionSet holds sorted, non-overlapping offset ranges. Collapsed
ranges are cursors:
var selections = TextSelectionSet([
const TextSelectionRange(startOffset: 1, endOffset: 1),
TextSelectionRange.directional(anchorOffset: 12, activeOffset: 8),
], primaryOffset: 8);
selections = selections.add(
const TextSelectionRange(startOffset: 20, endOffset: 20),
);
Construction normalizes bounds and merges overlapping or touching ranges.
TextSelectionRange retains direction separately: anchorOffset is fixed,
activeOffset moves, and isReversed reports whether the active edge is the
lower bound. Use TextSelectionRange.directional when constructing from
anchor/active offsets. Use primary for status displays and provider requests.
Use applyInsertion/applyDeletion when maintaining selection state outside
an integrated editor.
For direct list-based transformations,
insertTextAtEachSelection and the other multi-range helpers apply edits in
descending offset order so earlier offsets remain stable.
Range transforms
EditorRangeResolver is the host-independent motion contract:
typedef EditorRangeResolver = TextSelectionSet Function(
TextDocument document,
TextSelectionSet current,
);
The range helpers are:
mapSelectionRanges, which transforms every complete range and preserves the primary selection;mapSelectionEnds, which moves or extends every range's active edge while preserving its anchor;textOffsetOnAdjacentVisibleLine, which resolves fold-aware vertical movement and accepts a preferred column retained by the host.
When mapSelectionEnds moves instead of extending, a directional motion first
collapses an existing selection toward that direction. This is the usual arrow
key behavior and applies identically to a single selection and a selection set.
Repeated backward extension remains backward, and crossing the anchor changes
direction without changing the anchor.
For vertical movement, capture the cursor column at the start of a movement
sequence and pass it back as preferredColumn. Short lines clamp the resulting
offset without replacing that preference, so a later long line restores the
intended column. Reset the preference after horizontal movement or editing.
TextAreaModel does this per cursor automatically. With soft wrapping enabled,
it navigates adjacent visual rows and retains a terminal-cell display column;
without wrapping, it navigates logical lines and retains a grapheme column.
Home/End dispatch the visual-line boundary commands. The distinct
cursorLineStart, cursorLineEnd, selectLineStart, and selectLineEnd
commands remain available when a host needs explicit logical-line movement.
Page Up/Page Down dispatch page commands that move by the textarea's viewport
height through the same visual-row projection. Shift extends each selection,
and folded lines remain skipped.
Ctrl+Home/Ctrl+End dispatch document-boundary movement; adding Shift dispatches
the corresponding selectDocumentStart/selectDocumentEnd command.
A host can package a structural motion as an EditorRangeResolver and apply it
to every selection rather than duplicating single- and multi-cursor paths.
Use one resolver for:
- moving every cursor;
- extending every selection;
- character, word, line, or syntax-node motions;
- fold-aware vertical navigation.
This is especially useful for Vim-like or structural editing layers: the mode decides which resolver to invoke, while document and selection rules remain in the shared core.
Extmarks
TextExtmarksController tracks arbitrary anchored ranges as edits occur.
Extmarks are appropriate for bookmarks, breakpoints, semantic anchors, or
virtual annotations:
final marks = TextExtmarksController();
final id = marks.create(
const TextExtmarkOptions(
startOffset: 3,
endOffset: 8,
type: 'bookmark',
styleKey: 'bookmark.active',
data: {'name': 'entry'},
),
);
marks.applyInsertion(offset: 0, text: '// ');
final moved = marks.get(id);
Call applyInsertion, applyDeletion, or applyReplacement for every
document edit. Query marks with getAtOffset, getAllForType, and
getVirtual.
Extmarks, decorations, placeholders, and inline elements
These types solve different problems:
| Type | Purpose |
|---|---|
TextExtmark | Long-lived anchored metadata that tracks edits |
TextDecorationRange | A semantic style span for the current render state |
TrackedPlaceholderRange | Display text that expands to hidden full text |
InlineElement | A typed document span associated with host-owned sidecar data |
TextDiagnosticRange | A problem span with severity and message |
Do not use marker text alone as identity. Keep a stable extmark or inline element ID and associate external records with that ID.
Coordinate checklist
When integrating another protocol:
- Determine whether its offsets are UTF-8 bytes, UTF-16 code units, Unicode scalars, graphemes, or line/column positions.
- Convert into
TextDocumentgrapheme coordinates at the boundary. - Keep all editor operations in grapheme coordinates.
- Convert back only when calling the external protocol.
The pure applyFileEdits/applyWorkspaceEdit helpers operate on Dart
String.substring indexes and therefore use UTF-16 code units. Treat them as
an external string boundary rather than mixing their offsets into
TextDocument operations.
TextPositionDiagnosticRange.toOffsetRange(document) performs the
line/column-to-offset conversion for diagnostics already expressed in editor
line and column coordinates.