Reaching sub-millisecond input latency on 100K-line files using Kotlin Multiplatform, Skia, and Virtualized canvas layers
If you have ever tried building a serious text or code editor in Jetpack Compose or Compose Multiplatform using BasicTextField, you already know where the ceiling is.
Declarative UI frameworks excel at forms, chat inputs, and short documents. However, the moment you load a 50,000-line source file, attach real-time syntax highlighting, spawn multiple cursors, and scroll rapidly at 120Hz, the standard composable pipeline starts gasping for air. Recreating AnnotatedString instances on every keystroke, measuring thousands of nested layout nodes, and allocating objects on the hot rendering path inevitably leads to garbage collection pauses and dropped frames.
To solve this properly across Desktop (JVM), Android, iOS, and WebAssembly (Wasm-JS), I built Kortex — an open-source, hardware-accelerated code and text editor engine for Kotlin Multiplatform.
Here is a deep dive into the architecture behind Kortex, why it bypasses standard Compose text components entirely, and how it achieves locked 60+ FPS rendering on massive documents.
1. The Core Philosophy: Zero Composable-Per-Line Overhead
In a traditional declarative list (like a LazyColumn of text rows), every visible line is a separate composable node with its own layout pass, modifier chain, and composition state. When you scroll horizontally and vertically simultaneously, or render cross-line selections and diagnostic squiggles, coordinating state across individual row composables becomes an intractable bottleneck.
Kortex treats the entire editor viewport as a single hardware-accelerated surface.
Instead of delegating text rendering to Compose UI nodes, Kortex drops down directly to Skia (via Skiko) using a platform-specific SkiaCanvas abstraction:
// JVM, iOS, and WasmJS implementation
@Composable
actual fun SkiaCanvas(
modifier: Modifier,
onRender: (canvas: Canvas, width: Int, height: Int) -> Unit
) {
Box(
modifier = modifier.drawWithCache {
val width = size.width.toInt()
val height = size.height.toInt()
onDrawBehind {
if (width <= 0 || height <= 0) return@onDrawBehind
drawIntoCanvas { canvas ->
onRender(canvas.nativeCanvas, width, height)
}
}
}
)
}
On Android, SkiaCanvas binds directly to an embedded SkikoSurfaceView and SkiaLayer. State changes that require repaints (such as cursor blinking or text input) trigger frame updates via SideEffect { surfaceView?.scheduleFrame() }, guaranteeing identical 60+ FPS Skia rendering semantics across all four targets without forcing recompositions across the entire Compose UI tree.
The Layered Rendering Pipeline
Every frame inside EditorCanvas is composed of lightweight, pre-calculated Layer primitives rendered in a strict Z-order pass:
-
Background Canvas (
BackgroundLayer) -
Gutter Background (
gutterBackgroundLayer) -
Unified Active Line Highlight (
HighlightedLineLayerrendered across the full viewport width) -
Isolated Gutter Elements (
GutterLineLayer,FoldingMarkersLayer,GutterSeparatorLayerclipped togutterWidth) -
Code Viewport Matrix Shift & Clip (
nativeCanvas.translate(gutterWidth, 0f)clipped to code width) -
Underlay Guides & Markers (
ColumnRulerLayer,OccurrencesHighlightLayer) -
Multi-Selection Regions (
SelectionLayer) -
Syntax-Highlighted Text Paragraphs (
TextContentLayer) -
Diagnostic Sine-Wave Squiggles (
DiagnosticsLayer) -
Blinking Multi-Carets (
CaretLayer)
Because the line-number gutter is isolated through its own clip bounds and the code area is shifted with matrix translations, horizontal code scrolling never bleeds into the gutter, and the entire viewport paints in a single unified GPU pass.
2. Backed by Krope: Logarithmic Text Mutations
A fast renderer is useless if typing a character copies a 10MB String in memory.
Under the hood, Kortex is powered by Krope, a thread-safe, persistent Red-Black Rope text buffer built specifically for high-throughput text processing.
- Logarithmic Edits: Insertions, deletions, and range replacements execute in $O(\log N)$ time with structural sharing.
-
Immutable Snapshots: The UI and background analysis pipelines read from an immutable
TextSnapshottied to a monotonicTextRevision. -
Delta Position Mapping: When a batch edit occurs (for example, typing simultaneously with 15 active cursors), Kortex sorts operations in descending document order so earlier edits never invalidate the offsets of later ones. Once the rope commits the transaction, it returns a
TextEdit.Datadelta, andEditorEnginemaps every surviving selection and caret through that delta mathematically:
private fun mapSingleDelta(position: TextPosition, delta: TextEdit.Data.Single): TextPosition {
val start = delta.startPosition
val oldEnd = delta.oldEndPosition
val newEnd = delta.newEndPosition
return when {
position < start -> position
position in start..oldEnd -> newEnd
else -> {
val lineDelta = newEnd.line - oldEnd.line
when (position.line) {
oldEnd.line -> {
val colDelta = newEnd.column - oldEnd.column
TextPosition(line = position.line + lineDelta, column = position.column + colDelta)
}
else -> TextPosition(line = position.line + lineDelta, column = position.column)
}
}
}
}
For structural line operations like MoveLineUp and MoveLineDown (Alt + Up/Down), calculating position via delta projection alone would snap the caret to the edge of the replaced range. Kortex resolves this by supporting explicit targetSelections inside EditorCommand.ApplyText, enabling line swaps to carry their carets and selections to adjacent lines without caret drift.
3. Killing GC Pauses with Bounded Native LRU Caches
Skia objects like Paragraph, TextLine, and Paint wrap native C++ resources. Allocating and destroying them on every 16ms frame (or 8ms on 120Hz ProMotion displays) quickly overwhelms the memory allocator.
Kortex solves this with a custom thread-safe LruCache extending CloseableResource. Whenever an item is evicted from the cache—or when the editor composable leaves the composition tree—its underlying native Skia pointer is deterministically closed:
internal class CodeParagraphCache(capacity: Int = 1000) : LruCache<CodeParagraphCache.Key, Paragraph>(capacity) {
data class Key(
val line: Int,
val text: String,
val tokens: List<Token>,
val font: EditorFont,
val syntaxTheme: SyntaxTheme,
)
override val factory: Key.() -> Paragraph = {
// Builds and lays out the Skia Paragraph only on a cache miss
...
builder.build().apply { layout(Float.MAX_VALUE) }
}
}
Crucially, this zero-allocation philosophy extends to input gestures. Dragging the mouse to select text calls getOffsetAtCoordinate on every pixel change. Re-parsing a Skia paragraph on each pointer move would create unacceptable jank. In Kortex, EditorFont manages an internal measurementParagraphCache:
private val measurementParagraphCache = object : LruCache<String, Paragraph>(capacity = 128) {
override val factory: String.() -> Paragraph = {
val textToMeasure = this
TextStyle().apply {
this.fontFamilies = arrayOf(this@EditorFont.familyName)
this.fontSize = this@EditorFont.size
}.use { baseTextStyle ->
ParagraphStyle().apply {
this.maxLinesCount = 1
this.textStyle = baseTextStyle
}.use { style ->
buildParagraph(style) {
pushStyle(baseTextStyle)
addText(textToMeasure)
popStyle()
}.use { builder ->
builder.build().apply { layout(Float.MAX_VALUE) }
}
}
}
}
}
During continuous 120 FPS scrolling, cursor blinking, or rapid mouse selection, Kortex hits its bounded caches with near-100% hit rates, resulting in zero native allocations on the render loop.
4. Viewport Virtualization & Logarithmic Code Folding (FoldMap)
How do you render a 200,000-line file when 50 different functions and classes are collapsed?
Iterating through lines to check which ones are hidden would turn rendering into an $O(N)$ operation. Instead, Kortex compiles collapsed regions into an immutable FoldMap—a sorted, merged interval table storing cumulative hidden line counts and visual thresholds:
internal data class Interval(
val startLine: Int,
val endLine: Int,
val hiddenCount: Int,
val cumulativeHiddenBefore: Int,
val visualStartThreshold: Int,
)
Converting between a Visual Line (what the user sees on screen) and a Document Line (the actual index in the Rope buffer) is a binary search over active intervals running in $O(\log K)$ time, where $K$ is the number of folded blocks:
-
Viewport.of(...)calculates the exact visible visual line slice (startVisualLine..endVisualLine) fromscrollYandlineHeight. - Each visible line is mapped to its true document line via
foldMap.visualToDocumentLine(visualLineIndex). - Collapsed lines are completely culled before Skia ever touches them.
5. Pure Unidirectional MVI & Smart Undo/Redo Coalescing
UI components in Kortex never mutate state directly. The entire :kortex-core module is completely headless (zero Compose dependencies) and operates as a deterministic state machine:
(EditorState, EditorAction) -> EditorResult(newState, EditorCommand?)
-
Pure Reducers:
CaretReducer,SelectionReducer,EditReducer, andViewportReducercompute next states purely in memory. -
Multi-Cursor Normalization: Every time cursors move or expand,
Selection.normalize(...)sorts all active selections and automatically merges overlapping or touching ranges while preserving selection direction (FORWARDvs.BACKWARD), surrogate pairs (emoji), andstickyColumnoffsets. -
Sliding-Window Undo/Redo:
HistoryManagerrecords forward and inverseTextOperation.Datacommands and automatically merges contiguous single-line insertions or deletions if they occur within a 700ms window—restoring exact multi-cursor positions when undone.
6. Full IDE Tooling: Async Analysis, Squiggles & Hierarchical Themes
On top of the plain-text :kortex-core engine sits :kortex-code and :kortex-code-compose, turning the editor into a complete IDE component.
Debounced Coroutine Pipelines
CodeEditorEngine binds to a pluggable LanguageAnalysisService using Kotlin Coroutine Flows on Dispatchers.Default:
-
Tokenization (
TokenProvider): Debounced at 50ms, dropping stale snapshots viadistinctUntilChangedBy(TextSnapshot::revision)andmapLatest. Existing folding regions and symbol occurrences are preserved during token updates. -
Diagnostics (
DiagnosticsProvider): Debounced at 300ms, feedingDiagnosticsLayerto draw procedural sine-wave/zigzag paths directly under erroneous tokens. -
Hover Documentation (
DocumentationProvider): Debounced at 250ms, resolving documentation for the symbol under the primary caret and updatingCodeEditorState.documentation. -
IntelliSense (
CompletionProvider): Floating popup anchored to the active caret's visual coordinates with keyboard navigation (Up,Down,Tab/Enter,Escape).
Hierarchical Token Theming
Inspired by TextMate and VS Code scopes, SyntaxTheme resolves token colors hierarchically using dot-separated fallbacks. If your lexer emits keyword.control and the active theme only defines a color for keyword, Kortex walks up the scope tree automatically:
val syntaxTheme = SyntaxTheme.build(defaultText = Color(0xFFABB2BF)) {
put(TokenType.KEYWORD.name, Color(0xFFC678DD))
put(TokenType.KEYWORD_CONTROL.name, Color(0xFFE06C75)) // Overrides "keyword"
put(TokenType.FUNCTION.name, Color(0xFF61AFEF))
put(TokenType.STRING.name, Color(0xFF98C379))
}
Getting Started in 30 Seconds
Kortex is published on Maven Central and divided into four modular artifacts so you only pull what you need:
dependencies {
implementation("io.github.numq.kortex:core:1.1.0") // Headless engine
implementation("io.github.numq.kortex:core-compose:1.1.0") // Plain text Skia editor
implementation("io.github.numq.kortex:code:1.1.0") // Code analysis engine
implementation("io.github.numq.kortex:code-compose:1.1.0") // Full IDE Composable
implementation("io.github.numq.krope:text:1.1.1") // Rope text buffer
}
Embedding a full-featured code editor in your Compose Multiplatform app takes just a few lines:
@Composable
fun IdeEditorScreen() {
val scope = rememberCoroutineScope()
val buffer = remember {
RopeTextBuffer(
initialText = "fun main() {\n println(\"Hello, Kortex!\")\n}",
initialLineEnding = TextLineEnding.LF,
initialEncoding = Encoding.UTF8,
enablePooling = true
)
}
val editorEngine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
val codeEngine = remember(editorEngine) {
CodeEditorEngine(
scope = scope,
engine = editorEngine,
analysisService = myLanguageAnalysisService
)
}
val font = remember {
val typeface = FontMgr.default.matchFamilyStyle("JetBrains Mono", FontStyle.NORMAL)
?: Typeface.makeEmpty()
EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.25f)
}
DisposableEffect(font) {
onDispose {
font.close()
}
}
val theme = rememberCodeEditorTheme()
KortexCodeEditor(
engine = codeEngine,
font = font,
theme = theme,
columnRuler = 80,
modifier = Modifier.fillMaxSize()
)
}
Out of the box, you get Alt + Click multi-cursors, Ctrl/Cmd + D next-occurrence selection, code folding, smart indentation, line moving (Alt + Up/Down), comment toggling (Ctrl/Cmd + /), right-click context menus, and native desktop scrollbar adapters.
Explore the Source Code
Both Kortex and Krope are 100% open-source under the Apache 2.0 License. Whether you are building a developer tool, a Markdown note-taking app, a database query console, or a full multiplatform IDE, I would love for you to try it out and share your feedback!
- Kortex: github.com/numq/kortex
- Krope: github.com/numq/krope
Top comments (0)