Editing and commands
The editor core supports two complementary styles:
- pure functions that return a changed document and state; and
TextAreaModel, which combines those rules with history, commands, paste scheduling, rendering state, and TEA messages.
Pure editing functions
Pure editing functions accept a TextDocument plus an immutable state
snapshot and return a result:
final document = TextDocument(text: 'hello');
final state = TextOffsetStateSnapshot.collapsed(cursorOffset: 5);
final result = textInsertText(
document: document,
state: state,
text: ' world',
);
if (result.changed) {
final nextDocument = result.document!;
final nextState = TextOffsetStateSnapshot.collapsed(
cursorOffset: result.cursorOffset,
);
print(nextDocument.text);
}
TextCommandResult may carry a document, cursor/selection offsets, and a
TextDocumentChange. TextCursorCommandResult changes only cursor state.
TextLineCommandResult operates on line text and line/column state.
Available operation families
- Insertion and deletion: selection replacement, previous/next grapheme, previous/next word, and deletion to line boundaries.
- Navigation: character, word, document boundary, visual line, and visual line boundary.
- Selection transforms: selection/line case conversion, wrapping, unwrapping, and adjacent-word transforms.
- Line operations: split, join, indent, outdent, move, duplicate, delete, whitespace cleanup, and sorting.
- Markdown-like operations: line prefixes, numbered lists, headings, and checklists.
- Code behavior: auto-pairs, pair backspace, closing-delimiter alignment, indented newline, and block comments.
Use the text*Document variant when one exists to preserve
TextDocumentChange and incremental storage behavior.
Integrated editing with TextAreaModel
import 'package:artisanal/bubbles.dart';
import 'package:artisanal/editor_core.dart';
final editor = TextAreaModel(
width: 100,
height: 30,
showLineNumbers: true,
useVirtualCursor: true,
)
..setText('one\ntwo\nthree', recordHistory: false)
..focus();
editor.setSelections(
TextSelectionSet([
const TextSelectionRange(startOffset: 0, endOffset: 0),
const TextSelectionRange(startOffset: 4, endOffset: 4),
]),
);
editor.insertString('> ');
Useful state:
value,document,lineCount, andlengthline,column,cursorOffset, andeditorStateselections,hasSelection, andhasMultipleSelectionscanUndo,canRedo, andisDirtyconsumeLastDocumentChange()for incremental tooling
Transactions and history
TextAreaModel records normal edits automatically:
editor.insertString('first');
editor.undo();
editor.redo();
Use editTransaction when several operations should be one undo step:
editor.editTransaction((model) {
model.insertString('/* ');
model.insertString(' */');
});
Use:
pushHistoryBoundary()to stop adjacent edits from coalescing;clearHistory()after loading a document that should become the new baseline;markSaved()after a durable save.
Programmatic calls that conceptually belong to one user action should be wrapped in one transaction rather than manually manipulating history.
Multiple cursors
The integrated editor exposes commands for:
- adding a cursor above or below;
- adding the next occurrence of the current selection;
- moving all cursors horizontally, vertically, by word, or to line boundaries;
- extending all selections;
- inserting and deleting at all selections;
- indenting, outdenting, moving, duplicating, and deleting selected lines.
editor
..selectCurrentLine()
..addNextOccurrence();
editor.executeCommand(EditorCommandIds.cursorWordRight);
editor.executeCommand(EditorCommandIds.insertText, argument: 'value');
Multi-range edits are applied from highest offset to lowest. This avoids invalidating ranges that have not yet been processed.
Stable commands
EditorCommand separates intent from keyboard events:
final registry = EditorCommandRegistry<TextAreaModel>()
..register(
EditorCommand(
id: 'app.editor.format',
label: 'Format Document',
category: 'Source',
isEnabled: (editor) => editor.length > 0,
execute: (editor) => editor.cleanupWhitespace(),
),
);
final result = registry.dispatch('app.editor.format', editor);
A command has:
- a stable
id; - user-facing
label,description, andcategory; - an optional
isEnabledpredicate; - an
executehandler; - an optional
executeWithhandler for arguments such as inserted text or snippet source.
Duplicate IDs are rejected unless replace: true is explicit. This prevents a
plugin or mode from silently shadowing another command.
TextAreaModel.commandRegistry is pre-populated with shared
EditorCommandIds. Invoke it through executeCommand; add application
commands to the same registry so keyboard shortcuts and palettes share one
source of truth.
TextAreaModel.update uses this registry for ordinary editing too. Typed
characters dispatch EditorCommandIds.insertText with an argument; newline,
arrows, word/line/document motions, deletion, indentation, and folds dispatch
their corresponding IDs. There is no separate private behavior path for those
keys.
Transpose and word-case bindings likewise dispatch transposeCharacters,
uppercaseWord, lowercaseWord, and capitalizeWord, so palettes and modal
hosts can invoke the same undoable transforms.
The registry also exposes line joining/splitting, whitespace cleanup, sorting,
and selection-or-line case conversion. These operations are available to
command palettes without requiring a terminal key binding.
wrapSelection accepts either a string for symmetric delimiters or an
EditorWrapSelectionArgument for distinct opening and closing text;
unwrapSelection removes recognized surrounding pairs.
Markdown-oriented commands expose prefix toggling, numbered-list
toggle/renumber, heading toggle, and checklist state. Their optional arguments
use simple portable values: String for prefixes/markers and int for list
starts/heading levels.
Other configurable commands accept an integer indentation width, a boolean
cleanupWhitespace trailing-line policy, or EditorSortLinesArgument for
descending and case-sensitive sorting.
This means a host may consume a key before TextAreaModel.update and dispatch
the same command itself:
editor.executeCommand(EditorCommandIds.cursorWordRight);
editor.executeCommand(EditorCommandIds.insertText, argument: 'value');
The host changes input policy, not editing semantics.
Portable keymaps
The editor core does not parse terminal key events. Normalize host events into
chords, then resolve them with EditorKeymap:
final keymap = EditorKeymap()
..bind(
const EditorKeyBinding(
chord: 'ctrl+z',
commandId: EditorCommandIds.undo,
),
)
..bind(
const EditorKeyBinding(
chord: 'g g',
commandId: EditorCommandIds.cursorDocumentStart,
when: 'mode == normal',
),
);
final commandId = keymap.resolve(
'g g',
evaluateWhen: (expression) => expression == 'mode == normal',
);
Later bindings have higher priority. when expressions are opaque strings;
the host supplies their evaluator.
Command palettes
EditorCommandPalette<T> filters enabled commands, maintains selection, and
dispatches the selected command:
final palette = EditorCommandPalette(
registry: editor.commandRegistry,
target: editor,
)..open();
palette.updateQuery('undo');
palette.moveSelection(1);
final result = palette.executeSelected();
This is UI-independent state. TEA hosts can use
CommandPaletteOverlay/CommandPaletteComponent from bubbles.dart; widget
hosts use the themed widget component. Both delegate matching, selection, and
visible-window calculation to the same Artisanal command-palette controller.
Always render a bounded window instead of slicing the first N commands. Use
visibleWindow(viewportSize:) so moving the selection keeps it on screen.
Modal editing
A modal editor should layer modes over commands rather than fork document operations:
- Normalize an event into a chord.
- Resolve the chord in the active mode/context.
- Dispatch a stable command or apply an
EditorRangeResolver. - Render the mode separately in the status line.
Insert, normal, visual, and operator-pending modes can therefore share the same document, undo, selection, folding, diagnostics, and tooling sessions.