DEV Community

Cover image for Why BasicTextField Fails at Scale — And How I Build Kortex
numq
numq

Posted on Originally published at Medium

Why BasicTextField Fails at Scale — And How I Build Kortex

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)
                }
            }
        }
    )
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. Background & Active Line Highlight (BackgroundLayer, HighlightedLineLayer)
  2. Gutter & Code Folding Markers (GutterLineLayer, FoldingMarkersLayer, GutterSeparatorLayer)
  3. Viewport Translation & Clipping (nativeCanvas.translate(gutterWidth, 0f))
  4. Column Ruler (ColumnRulerLayer)
  5. Symbol Occurrences & Multi-Selection Regions (OccurrencesHighlightLayer, SelectionLayer)
  6. Syntax-Highlighted Text Paragraphs (TextContentLayer)
  7. Diagnostic Sine-Wave Underlines (DiagnosticsLayer)
  8. 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 TextSnapshot tied to a monotonic TextRevision.
  • 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.Data delta, and EditorEngine maps 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)
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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) }
    }
}

Enter fullscreen mode Exit fullscreen mode

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,
)

Enter fullscreen mode Exit fullscreen mode

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:

  1. Viewport.of(...) calculates the exact visible visual line slice (startVisualLine..endVisualLine) from scrollY and lineHeight.
  2. Each visible line is mapped to its true document line via foldMap.visualToDocumentLine(visualLineIndex).
  3. 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?)

Enter fullscreen mode Exit fullscreen mode
  • Pure Reducers: CaretReducer, SelectionReducer, EditReducer, and ViewportReducer compute 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 (FORWARD vs. BACKWARD) and stickyColumn offsets.
  • Sliding-Window Undo/Redo: Nobody wants Ctrl+Z to undo one letter at a time. HistoryManager records forward and inverse TextOperation.Data commands 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 via distinctUntilChangedBy(TextSnapshot::revision) and mapLatest.
  • Diagnostics (DiagnosticsProvider): Debounced at 300ms, feeding DiagnosticsLayer to 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))
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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()
    )
}

Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)