

Updates to the Aardink editor v0.7.0

I released Aardink 0.7.0 on 24 October 2026. 0.6 gave the editor its features. 0.7 hands them to the app around it.
The list came from checking the two aardflex apps against the 0.6 API and writing down every workaround. The Android app refreshed its preview from the caret callback and ran its own 500 ms timer for diagnostics, so an edit that left the caret where it was went unnoticed. It had no way to put a Format button on a phone. The web app ranked completions itself, because the editor’s filter dropped its matches. Each of those is an API now.
Two patch releases sit either side. 0.6.3 on 22 October stopped F8 typing a w on the web and made $1 work in regex
replace. 0.7.1 on 1 November fixed the desktop, where 0.7.0 dropped every letter and digit you typed. Only keys with a
binding, like Enter and Backspace, got through. Compose wraps the AWT key event in its own, and the editor never
unwrapped it. Use 0.7.1.
Upgrading
Most of 0.7 is new API. Three changes break source, and only if you write a language service or build diagnostics yourself:
smartIndentreturns the indentation as a string, and it,formatandformatRangetakeFormattingOptions.Diagnostic.lineNumberis gone. The old constructor still compiles, deprecated, and ignores the line.CompletionKindandDiagnosticSeverityhave new entries, so an exhaustivewhenover either needs another branch.
The first one exists because 0.6 returned a number of spaces, so a service could never indent with tabs:
// 0.6
override fun smartIndent(document: CodeDocument, lineIndex: Int): Int
// 0.7
override fun smartIndent(document: CodeDocument, lineIndex: Int, options: FormattingOptions): String {
if (lineIndex <= 0) return ""
val previous = document.lineText(lineIndex - 1)
return options.indentAfter(previous, levels = if (previous.trimEnd().endsWith("{")) 1 else 0)
}
The editor passes its tabSize and insertSpaces, and the built-in formatters follow them instead of assuming four
spaces.
Every data class and composable that gained a parameter changed its binary signature, so recompile against 0.7. Calls by name or by position still compile.
Two behaviour changes are worth knowing. Enter and paste in a CR LF file insert CR LF now, instead of mixing in LF. And Ctrl+F and Ctrl+H take a selection on one line as the query, the way VS Code does.
docs/MIGRATION_0.7.md has every change with a
before and after.
Driving the editor from the app
addContentListener reports every edit as a list of ContentChanges. Each one has the range it replaced, as an offset
and as lines and columns, the inserted text, the document’s version, and whether it came from typing, undo, redo or a
new text. That is enough for a language server’s incremental sync. contentChanges() gives the same as a Flow.
state.addContentListener { changes ->
if (changes.any { it.isReset }) preview.reload(state.text) else preview.patch(changes)
}
Listeners run synchronously, inside the edit. Don’t edit the text from one.
addSelectionListener reports the selections with a reason: keyboard, pointer, edit, undo, redo or API. isFocused is
snapshot state, and CodeDocument.snapshot() is public, so a copy for another thread no longer means
CodeDocument(document.text).
Commands run what a key or a menu would. state.execute(EditorCommand.Format) formats, and there are commands for
completions, hover, quick fixes, rename, go to definition, references, next and previous problem, find, replace, go to
line, folding, snippets and undo. availableCommands is snapshot state, so a button can enable itself:
Button(
onClick = { state.execute(EditorCommand.NextProblem) },
enabled = EditorCommand.NextProblem in state.availableCommands,
) { Text("Next problem") }
Commands that open something on screen, like completions or rename, run only while a CodeEditorLayout is composed.
The part I care about most is the phone. Without a keyboard there is no F12 and no Shift+Alt+F, so in 0.6 half the editor was out of reach on Android. Now the keyboard toolbar takes your own characters and buttons after the language’s:
CodeEditorLayout(
state = state,
keyboardToolbarItems = listOf(
KeyboardToolbarItem.Character('|'),
KeyboardToolbarItem.Character('$'),
KeyboardToolbarItem.Action("Format", command = EditorCommand.Format),
),
)
touchMenuItems and contextMenuItems add to the touch menu and the right-click menu. The touch menu offers Format and
Quick Fix by itself when the language service can do them, and scrolls sideways when it runs out of room.
Diagnostics
A diagnostic carries a code and a link to its docs, tags, relatedInformation, and a new Hint severity. Code
tagged unnecessary is drawn faded and deprecated code is struck through. A hint is three dots under its start, with no
gutter dot. A range can run over several lines.
Rest the mouse on a squiggle and its message, source and code show above the hover docs, with a Quick Fix button. On a touch screen, tap it. F8 and Shift+F8 step through the problems and show each in a banner with its place in the list. Escape closes it.
One fix from the same work: a gutter dot used to show the least severe diagnostic on its line. A line with an error and an info got the info’s dot.
Completions
The editor orders the list by sortPriority and keeps the service’s order among equals. A service that sets
completionsAreIncomplete = false is asked once per word, and the editor narrows the list by filterText as you keep
typing, the way VS Code does. The default stays true, which asks again on every letter, as 0.6 did.
quickSuggestions opens the list on letters typed in code. detail shows beside the label, commitCharacters accept
the selected item when typed, and CompletionKind has Monaco’s kinds. The aardflex web app can delete its own ranking
code.
XML from a schema
Aardflex’s XML language service existed twice: about 1,200 lines of Kotlin for Android and 2,900 of TypeScript for the web. The two already disagreed. Most of what they did follows from a description of the dialect, so 0.7 takes the description instead.
XmlSchema.parse(json) reads a dialect’s elements, attributes, values, nesting, required attributes and docs.
XmlSchemaLanguageService turns it into:
- completions by what the element at the caret may hold, inserted with their required attributes and a choice of values
- hover docs
- checks for unknown elements and attributes, elements in the wrong place, values outside an attribute’s list, and required attributes left out
It sits on top of the built-in XML language, so well-formedness checks, closing tags, indentation and formatting still come from there. The built-in XML’s Android lists are now a default schema that checks nothing.
The same idea works for any language. CompositeLanguageService(primary, fallback) and
LanguageDefinition.extending(base, overlay) put a dialect’s service over a base language’s, which is what the web’s
extends already did. The Android app replaced the built-in XML service outright and lost its tag checks and closing
tags. Composing puts them back underneath.
EditorThemeParser.fromMonacoJson reads Monaco’s defineTheme data, so one theme file serves the web editor and the
Android app.
Keys
- F2 renames, also from the right-click menu.
- Ctrl/Cmd+. shows the quick fixes at the caret.
- F3 and Shift+F3 go to the next and previous find match.
- Alt+Enter selects every match.
On the web
The npm package gained most of the Monaco API that code written against Monaco reaches for:
editor.onDidChangeContent((text, e) => sync(e.changes));
editor.onDidChangeCursorSelection((e) => showPosition(e.selection));
editor.addCommand("CtrlCmd+S", save);
editor.trigger("toolbar", "editor.action.formatDocument");
editor.revealLineInCenter(42);
The changes are in Monaco’s IModelContentChange shape, with one difference that bites if you port Monaco code
unchanged. Monaco gives every range in the old text. Aardink gives each range in the text as the changes before it left
it, so you apply them in order.
Also onDidFocusEditorText and onDidBlurEditorText, getPosition and setPosition, addAction with keybindings and
a right-click menu entry, and getAction. setDiagnostics takes Monaco’s marker shape as it is. A completion answer
with incomplete: false gets the once-per-word behaviour above. registerLanguage({ extends: "xml", schema }) gives a
dialect the schema service.
The aardflex web app captured Ctrl+S and F8 itself, in the capture phase, to beat the editor to them. Those handlers can go.
Android under soft wrap
0.7.1 also fixed two soft wrap bugs on Android. A wrapped line’s rows were drawn further apart than the editor’s row height on some phones, so the last row overlapped the next line. And an indented line whose first word didn’t fit after its indentation broke before that word, leaving a first row of nothing but spaces. Android’s line breaker will start a new row after any white space, so the indentation is now laid out as invisible glyphs of the same width.
Try it here
This is the npm package, 0.7.0, with an Android layout that has two mistakes in it. Give it a go:
- Rest the mouse on a squiggle to read it.
- F8 and Shift+F8 step between the two problems. Escape closes the banner.
- Ctrl+Space inside a tag lists attributes.
- Select
wrap_contentand press Ctrl+F. The selection is the query. Alt+Enter then puts a caret on every match.
Downloads about 4.7 MB. Needs Chrome or Edge 119, Firefox 120, or Safari 18.2.
The setup for embedding it is in the 0.6.0 post and hasn’t changed.
Known limitations
-
A disposed editor still leaks about 320 KB. 0.7 ships on Compose Multiplatform 1.12.2, and the fix is in 1.13, which isn’t stable yet. Reuse one editor and call
setValueinstead of mounting one per view. -
A theme rule’s
backgroundis still not drawn, from VS Code or Monaco theme data.foregroundandfontStyleapply. -
The browser needs WebAssembly GC: Chrome or Edge 119, Firefox 120, or Safari 18.2.
0.8 is underway: document symbols and breadcrumbs, decorations, an overview ruler, and keybindings as data.