

Why the text field had to go

Outline
Notes and sources, not prose. Rewrite each section before publishing. Sources are in the aardink repo unless noted. Publish with 0.6.0-rc1.
TODO: opening with the numbers. 0.5 on the 5,000-line reference document (163 KB of Kotlin): 3.2 s from mount to idle, 3.7 s from a keystroke to idle, and 15 to 29 of 30 keys lost at 10 keys a second.
Where the time went
- The profile: Compose lays out the whole
BasicTextFieldas one paragraph twice a frame, once in measure and again inSkiaParagraph.paint. - Not the only whole-document cost: a full
AnnotatedStringper edit, the output transformation replaying every span, line positions for every line, the gutter starting at line 0, the diff lane over both texts, the line index rebuilt per edit, and every programmatic edit replacing the whole field. - On the web, Compose’s input wrote the whole text into a hidden textarea on every change.
- Conclusion: a new render and input path, not a tweak.
- Source:
AARDINK_0.6_PLAN.mdsection 1.
Draw only what is on screen
- A cache of per-line layouts, about twice the visible rows plus 64, keyed by line text, colours, style and wrap width.
- Only colour goes into a layout. Selection, find matches, squiggles and carets are drawn as rectangles on top, so moving the caret never lays out text again.
- Why not
LazyColumn: selection across items, horizontal scroll per item, keeping the gutter aligned, and one input target would all have to be rebuilt on top of it anyway. - Soft wrap: a Fenwick tree of rows per line. Estimates are exact for monospace ASCII, and scrolling is anchored so a correction never makes the view jump.
- Source:
AARDINK_0.6_PLAN.mdsections 4.5 and 4.6.
Take text input directly
PlatformTextInputModifierNodeandestablishTextInputSessionare public and common in Compose.- Android: our own
InputConnectionover the whole document, read in bounded slices. Moving a window around would forcerestartInput, which makes the keyboard flicker. - Desktop and web: Compose’s Skia text input request, whose members are still experimental. One file in
skikoMain, and a weekly canary build against Compose pre-releases. - Web: the input method sees 20 lines either side of the caret, at most about 4 KB each way, instead of the document.
- Source:
AARDINK_0.6_PLAN.mdsection 4.1,docs/spikes/0.6-input-renderer.md.
A document that does not copy itself
CodeDocumentbecame a gap buffer with a line index updated per edit, and implementsCharSequence. 10,000 keystrokes into 2 MB: about 20 ms on the JVM.- Tokenisers rescan from just before the edit until they line up with the old scan again. On the web, visible lines are highlighted first.
- Language services get an immutable snapshot. That fixed a data race on Android and the JVM, where a service could see the document change mid-request.
- Source:
CHANGELOG.mdUnreleased, Changed.
The spike was the real code
- PR 0 was meant to be a throwaway spike. It was built as the real PR 5 and 6 code behind a renderer switch, with the old text field as the default until the switch-over.
- Screenshot scenes for wrap, fold, squiggles and selection went in before the switch, so both renderers were compared on the same scenes.
- Source:
docs/spikes/0.6-input-renderer.md,AARDINK_0.6_PLAN.mdPR 7 Roborazzi policy.
Before and after
-
5,000 lines, 163 KB, headless Chrome:
Measurement 0.5 0.6 Mount to idle 3.2 s 82 ms Keystroke to idle, p50 3.7 s 8.6 ms Keystroke to change callback, p95 163 ms 2.6 ms setValueto idle444 ms 12 ms Keys lost, 30 typed at 10 keys a sec 15 to 29 0 -
CI now fails the build past 400 ms to mount, 50 ms p95 a keystroke, or any lost key.
-
Not proven yet, so do not claim it: input methods on real devices (Gboard, Samsung, Windows and macOS CJK) and TalkBack. TODO: update once the manual pass is done.
-
Source:
WEB_INTEGRATION.mdPerformance,AARDINK_0.6_PLAN.mdPR 7 table.