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 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 a 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 a SkikoSurfaceView and SkiaLayer, guaranteeing identical Skia rendering semantics across all four targets.
The Layered Rendering Pipeline
Every frame inside CodeEditorCanvas is composed of lightweight, pre-calculated Layer primitives rendered in a strict Z-order pass:
-
Background & Active Line Highlight (
BackgroundLayer,HighlightedLineLayer) -
Gutter & Code Folding Markers (
GutterLineLayer,FoldingMarkersLayer,GutterSeparatorLayer) -
Viewport Translation & Clipping (
nativeCanvas.translate(gutterWidth, 0f)) -
Column Ruler (
ColumnRulerLayer) -
Symbol Occurrences & Multi-Selection Regions (
OccurrencesHighlightLayer,SelectionLayer) -
Syntax-Highlighted Text Paragraphs (
TextContentLayer) -
Diagnostic Sine-Wave Underlines (
DiagnosticsLayer) -
Blinking Multi-Carets (
CaretLayer)
Because the gutter and the code area are isolated via Skia canvas matrix translations and clip rectangles, horizontal code scrolling never bleeds into the line-number gutter, and the entire editor draws 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 I 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 buffer 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)
}
}
}
}
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) will quickly overwhelm 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) }
}
}
During 60 FPS scrolling or caret blinking, Kortex hits CodeParagraphCache, TextLineCache, and PaintCache with near-100% cache hit rates, resulting in zero native allocations on the render thread.
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 the 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) andstickyColumnoffsets. -
Sliding-Window Undo/Redo: Nobody wants
Ctrl+Zto undo one letter at a time.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 full 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. -
Diagnostics (
DiagnosticsProvider): Debounced at 300ms, feedingDiagnosticsLayerto draw procedural sine-wave/zigzag paths directly under erroneous tokens. -
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.0.0") // Headless engine
implementation("io.github.numq.kortex:core-compose:1.0.0") // Plain text Skia editor
implementation("io.github.numq.kortex:code:1.0.0") // Code analysis engine
implementation("io.github.numq.kortex:code-compose:1.0.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)
}
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)