DEV Community

numq
numq

Posted on • Edited on • Originally published at Medium

Building High-Performance Text Editors in Kotlin Multiplatform with Krope

String and rope

If you have ever tried building a text editor, an IDE, or a collaborative document tool, you quickly run into a fundamental wall: how do you store the text?

At first glance, a standard String seems fine. But Strings are immutable; changing a single character in a 1MB file requires allocating a brand new 1MB array. So, you move to StringBuilder. It’s mutable and fast for simple appends, but inserting or deleting text in the middle of a massive document still requires shifting hundreds of thousands of characters in memory — an O(N) operation that causes UI stuttering as your document grows.

This is where the Rope data structure comes in.

Today, I want to introduce Krope, a high-performance, memory-safe, and reactive Rope library built entirely in Kotlin Multiplatform. Whether you are targeting JVM, Android, iOS, or WASM, Krope provides the engine you need to build next-generation text editing tools.

What is a Rope?

Instead of storing text as a single massive, contiguous array of characters, a Rope stores text as a balanced binary tree (in Krope’s case, utilizing a Red-Black tree variant). The leaves of the tree contain short chunks of strings.

When you insert text in the middle of a document, a Rope doesn’t copy the whole string. Instead, it splits a leaf node and inserts a new branch. This gives operations like insertion, deletion, and replacement a time complexity of O(log N).

Furthermore, Krope implements structural sharing. When a modification occurs, Krope doesn’t mutate the existing tree; it returns a new tree that shares the untouched branches with the old one. This provides safe concurrent access, cheap undo/redo history.

Why I Built Krope

The idea for Krope was born while building haskcore — a full-featured IDE for Haskell written in Compose Desktop. It uses Skia for rendering, Tree-sitter for incremental syntax parsing, and the Language Server Protocol for code intelligence. It’s a real editor with 40+ modules, handling everything from syntax highlighting to code completion and project-wide diagnostics.

Very quickly, I hit a wall: Kotlin’s standard StringBuilder couldn't keep up. Editing large Haskell modules meant shifting massive text buffers on every keystroke, causing visible lag during syntax highlighting and LSP synchronization. I needed something better — and the rope data structure, battle-tested in editors like Emacs and Vim since the 90s, was the obvious answer.

But Kotlin Multiplatform lacked a modern, reactive, and fully-featured rope implementation. So I built Krope to solve the specific pain points I encountered:

  • Cross-Platform Consistency: haskcore targets the desktop, but the same text engine should work on Android, iOS, and WASM for future ports or companion apps.
  • Reactive by Design: Compose Multiplatform thrives on reactive state. Krope exposes every edit as a SharedFlow event and every document state as a StateFlow, making UI integration seamless.
  • Byte-Level Encoding Awareness: LSP servers speak in byte offsets, not character positions. Krope natively tracks UTF-8, UTF-16, and UTF-32, giving you efficient access to byte coordinates backed by an LRU cache.
  • Functional & Safe: Invalid operations shouldn’t crash your editor. Krope uses an Either<Throwable, Result> pattern, so out-of-bounds inserts return errors you can handle gracefully — critical when processing remote edits from LSP or collaborative peers.

Inside the Krope API

Krope separates the underlying data structure from the editor API. The primary interface you interact with is the TextBuffer.

Here is how simple it is to get started:

import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.*
import kotlinx.coroutines.launch

// 1. Initialize the buffer
val factory = RopeTextBufferFactory()
val buffer = factory.create(
    text = "Hello World",
    encoding = Encoding.UTF8,
    lineEnding = TextLineEnding.LF,
    enablePooling = true // Enables internal string interning to save memory
).fold(
    onLeft = { error -> throw error },
    onRight = { it }
)
Enter fullscreen mode Exit fullscreen mode

The Reactive Paradigm

In a standard text editor, the UI needs to know exactly what changed to update the screen or send a diff over the network for collaborative editing. Krope exposes two flows:

  • snapshot: StateFlow<TextSnapshot>: Represents the immutable current state of the document.
  • data: SharedFlow<TextEdit.Data>: Emits granular events whenever the text changes.
// Observe all changes in real-time
launch {
    buffer.data.collect { editData ->
        when (editData) {
            is TextEdit.Data.Single.Insert -> 
                println("Inserted '${editData.insertedText}' at ${editData.startPosition}")
            is TextEdit.Data.Single.Delete -> 
                println("Deleted text ending at ${editData.oldEndPosition}")
            is TextEdit.Data.Single.Replace -> 
                println("Replaced '${editData.oldText}' with '${editData.newText}'")
            is TextEdit.Data.Batch -> 
                println("Batch processed ${editData.singles.size} operations atomically")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Line and Column Awareness

Most ropes just deal with raw character offsets. Krope natively understands Lines and Columns through TextPosition.

// "Hello Beautiful World"
buffer.insert(TextPosition(line = 0, column = 5), " Beautiful") 
Enter fullscreen mode Exit fullscreen mode
// Querying the snapshot is instantaneous thanks to cached tree node metadata
val snapshot = buffer.snapshot.value
println("Total Lines: ${snapshot.lines}")
println("Max Line Length: ${snapshot.maxLineLength}")

// Get byte offset for an LSP (Language Server Protocol)
val byteOffset = snapshot.getBytePosition(TextPosition(line = 0, column = 6))
Enter fullscreen mode Exit fullscreen mode

Atomic Batch Operations

When you refactor code (like renaming a variable that appears 10 times in a file), you don’t want to trigger 10 separate UI redraws or clutter the undo stack. Krope supports withBatch:

buffer.withBatch { batch ->
    batch.replace(TextRange(TextPosition(0, 39), TextPosition(0, 42)), "vatAmount")
    batch.replace(TextRange(TextPosition(1, 19), TextPosition(1, 22)), "vatAmount")
}
Enter fullscreen mode Exit fullscreen mode

This applies all edits internally, rebalances the tree once, and emits a single TextEdit.Data.Batch event to your listeners.

Performance: The 100 Megabyte Test

To answer the age-old question - "How does it perform with very large files?" - I ran JMH benchmarks on the JVM comparing Krope to StringBuilder.

We simulated the "Real Editor Scenario": 100 consecutive edits (insertions) occurring right in the middle of a document. We scaled the document size from 100,000 characters all the way to 100,000,000 characters (approximately 200 MB of text).

Here are the results (in milliseconds per 100 operations):

100,000 symbols

  • Krope: ~0.05 ms
  • StringBuilder: ~0.16 ms
  • Krope Advantage: ~3x faster

1,000,000 symbols

  • Krope: ~0.19 ms
  • StringBuilder: ~1.74 ms
  • Krope Advantage: ~9x faster

10,000,000 symbols

  • Krope: ~2.07 ms
  • StringBuilder: ~17.86 ms
  • Krope Advantage: ~8.5x faster

100,000,000 symbols

  • Krope: ~19.32 ms
  • StringBuilder: ~186.41 ms
  • Krope Advantage: ~9.6x faster

Why does StringBuilder choke?

Look at how the StringBuilder time multiplies by 10 every time the file size multiplies by 10. This is the curse of O(N) complexity. When you insert a single character in the middle of a 200MB StringBuilder, Java has to use System.arraycopy to physically shift 100MB of data in RAM to make room. Doing this 100 times forces the CPU to move 10 gigabytes of memory. This is exactly what causes UI thread stuttering and input lag in text editors.

Why does Krope win?

Because of structural sharing and its Red-Black tree foundation. When making 100 edits, Krope doesn't copy 100MB of arrays. It simply splits a small leaf node, updates a few pointers up to the root, and does a lightning-fast O(log N) tree rebalance.

(Note: In the benchmark above, the ~19.32 ms for Krope actually included the time to parse the 200MB string into a Rope tree from scratch on every iteration! The edits themselves are nearly instantaneous).

In a real-world typing or refactoring scenario, Krope maintains incredibly flat, predictable latency regardless of whether your file is 10 Kilobytes or 200 Megabytes, keeping your UI perfectly smooth while drastically reducing heap allocations.

Get Involved

If you are building text-heavy applications in Compose Multiplatform, writing a code editor, or experimenting with CRDTs for collaborative editing, give Krope a try.

The architecture is highly modular, separating the core data structures from the TextBuffer API, and includes built-in support for kotlinx.serialization.

Check out the source code, play with the examples, and star the repository on GitHub!

🔗 GitHub Repository: Krope

Top comments (0)